Files
OpenFlare/.agent/new-async-task/SKILL.md
T
2026-06-08 20:38:01 +08:00

13 KiB
Raw Blame History

name, description
name description
new-async-task 项目专用:指导如何在本项目中新增异步任务(TaskHandler),包括常量定义、参数传递(TaskParam)、处理器实现、Admin 校验、注册、Cron 调度、AppendLog 日志规范等完整步骤。

新增异步任务开发指南

本项目中异步任务基于 Asynq(Redis 任务队列)构建。开发者只需编写业务逻辑,框架负责执行记录、状态流转、日志、重试等全部外围工作。

架构概览

DispatchTask (创建记录 + 入队)
        │
        ▼
   Asynq Redis Queue
        │
        ▼
ProcessTask (状态 pending→running,调用 handler)
        │
        ▼
TaskHandler.Execute (← 你写这一步)
        │
   成功 → succeeded + 存储结果
   失败 → failed + 记录错误 + Asynq 可能自动重试

关键文件:

  • internal/task/handler.go — TaskHandler 接口、TaskResult 类型
  • internal/task/constants.go — 任务类型常量、TaskMeta、TaskParam、DispatchableTasks
  • internal/task/executor.go — 注册、下发、执行、日志追加
  • internal/task/worker/worker.go — Worker 启动、处理器注册、路由
  • internal/task/scheduler/scheduler.go — Cron 定时调度
  • internal/model/task_execution.go — TaskExecution GORM 模型
  • internal/apps/admin/task/routers.go — 管理 API(下发、查询、重试)

新增任务的完整步骤

第 1 步:在 constants.go 添加常量和 TaskMeta

打开 internal/task/constants.go,添加三样东西:

1. Asynq 任务类型常量(作为 Redis 消息类型标识,格式 {module}:{action}):

const CleanupUnusedUploadsTask = "upload:cleanup_unused"

2. 管理员下发用的任务类型常量(用于 Admin API 调用):

const TaskTypeCleanupUploads = "cleanup_unused_uploads"

3. 在 DispatchableTasks 切片中添加 TaskMeta:

不带参数的任务:

{
    Type:         TaskTypeCleanupUploads,
    AsynqTask:    CleanupUnusedUploadsTask,
    Name:         "清理未使用上传",
    Description:  "清理超过1小时未使用的上传文件",
    SupportsTime: false,
    MaxRetry:     3,
    Queue:        QueueDefault,
    Retryable:    true,
}

带参数的任务(Params 定义前端表单字段):

{
    Type:         TaskTypeSendEmail,
    AsynqTask:    SendEmailTask,
    Name:         "发送邮件",
    Description:  "异步发送系统邮件",
    SupportsTime: false,
    MaxRetry:     3,
    Queue:        QueueDefault,
    Retryable:    true,
    Params: []TaskParam{
        {
            Name:        "to",
            Label:       "接收邮箱 (To)",
            Type:        "string",
            Required:    true,
            Placeholder: "receiver@example.com",
            Description: "接收邮件的目标邮箱地址",
        },
        {
            Name:        "subject",
            Label:       "邮件主题 (Subject)",
            Type:        "string",
            Required:    true,
            Placeholder: "请输入邮件主题",
            Description: "发送邮件的主题标题",
        },
        {
            Name:        "body",
            Label:       "邮件内容 (Body)",
            Type:        "text",
            Required:    true,
            Placeholder: "请输入邮件内容(支持 HTML 格式)",
            Description: "发送邮件的内容主体",
        },
    },
}

TaskParam 字段说明:

字段 作用
Name 参数键名,与 Handler 中 Payload 结构体的 JSON tag 一致
Label 前端表单显示的标签
Type 前端控件类型:string(单行输入)、text(多行文本)、number(数字)
Required 前端是否标为必填(仅前端提示,不做服务端校验)
Placeholder 前端输入框占位文字
Description 前端显示的参数说明

注意:TaskParam 纯粹是前端表单元数据。服务端不基于它做参数校验——校验逻辑在 Admin dispatch handler 和 Handler Execute 中各写一份。

第 2 步:实现 TaskHandler

在 internal/apps/<module>/ 下创建 tasks.go。

无参数的任务(参考 internal/apps/upload/tasks.go):

type CleanupUnusedUploadsHandler struct{}

func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
    task.AppendLog(ctx, "开始扫描...")
    // ... 直接执行业务逻辑,忽略 payload ...
    return &task.TaskResult{Message: "完成"}, nil
}

带参数的任务(参考 internal/apps/user/tasks.go):

需要定义自己的 Payload 结构体,在 Execute 中反序列化:

// 定义载荷结构体(字段与 TaskParam.Name 对应)
type SendEmailPayload struct {
    To      string `json:"to"`
    Subject string `json:"subject"`
    Body    string `json:"body"`
}

type SendEmailHandler struct{}

func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
    // 第一步:反序列化参数
    var req SendEmailPayload
    if err := json.Unmarshal(payload, &req); err != nil {
        task.AppendLog(ctx, "解析参数失败: %v", err)
        return nil, fmt.Errorf("解析参数失败: %w", err)
    }

    task.AppendLog(ctx, "开始发送邮件到: %s, 主题: %s", req.To, req.Subject)

    // ... 业务逻辑 ...

    msg := fmt.Sprintf("邮件成功发送至: %s", req.To)
    task.AppendLog(ctx, "%s", msg)
    return &task.TaskResult{Message: msg}, nil
}

返回值约定:

  • 成功:返回 &task.TaskResult{Message: "摘要", Detail: "可选详细JSON"}。ProcessTask 会将 Message + "\n" + Detail 存入 TaskExecution.Result。
  • 失败:返回 nil, fmt.Errorf("...")。ProcessTask 会将错误信息存入 TaskExecution.ErrorMessage,状态标记为 failed,并将 error 返回给 Asynq 触发自动重试。

第 3 步:在 Admin dispatch handler 中添加参数校验

打开 internal/apps/admin/task/routers.go,在 DispatchTask 函数中为新任务类型添加校验逻辑。

下发请求结构体中,前端传入的参数通过 Payload 字段(JSON 字符串)传递:

type DispatchTaskRequest struct {
    TaskType  string     `json:"task_type" binding:"required"`
    StartTime *time.Time `json:"start_time"`
    EndTime   *time.Time `json:"end_time"`
    UserID    *uint64    `json:"user_id"`
    Payload   string     `json:"payload"`   // ← 前端传的参数 JSON 字符串
}

在 DispatchTask 函数中按 task type 分支校验:

var payloadBytes []byte
if req.TaskType == task.TaskTypeSendEmail {
    // 校验 payload 非空
    if strings.TrimSpace(req.Payload) == "" {
        c.JSON(http.StatusBadRequest, util.Err("任务参数 Payload 不能为空"))
        return
    }
    // 解析 JSON 并校验必填字段
    var mailPayload struct {
        To      string `json:"to"`
        Subject string `json:"subject"`
        Body    string `json:"body"`
    }
    if err := json.Unmarshal([]byte(req.Payload), &mailPayload); err != nil {
        c.JSON(http.StatusBadRequest, util.Err("无效的 JSON 格式: "+err.Error()))
        return
    }
    // Trim + 必填校验
    mailPayload.To = strings.TrimSpace(mailPayload.To)
    mailPayload.Subject = strings.TrimSpace(mailPayload.Subject)
    mailPayload.Body = strings.TrimSpace(mailPayload.Body)
    if mailPayload.To == "" || mailPayload.Subject == "" || mailPayload.Body == "" {
        c.JSON(http.StatusBadRequest, util.Err("to、subject、body 不能为空"))
        return
    }
    // 序列化回 bytes 传给 DispatchTask
    payloadBytes, _ = json.Marshal(mailPayload)
} else {
    // 无参数任务:直接透传 payload
    if req.Payload != "" {
        payloadBytes = []byte(req.Payload)
    }
}

taskID, err := task.DispatchTask(c.Request.Context(), req.TaskType, payloadBytes, "manual")

参数传递的完整链路:

前端表单(根据 TaskMeta.Params 动态渲染)
  → POST /api/v1/admin/tasks/dispatch { task_type, payload: JSON字符串 }
  → Admin DispatchTask handler 校验 + json.Marshal
  → task.DispatchTask(payloadBytes) 存入 DB + 入队 Asynq
  → ProcessTask → handler.Execute(payload) → handler 内 json.Unmarshal

第 4 步:在 worker.go 注册

打开 internal/task/worker/worker.go,做两件事:

1. 在 init() 中注册处理器:

func init() {
    task.RegisterHandler(task.CleanupUnusedUploadsTask, &upload.CleanupUnusedUploadsHandler{})
    task.RegisterHandler(task.SendEmailTask, &user.SendEmailHandler{})
}

2. 在 StartWorker() 的 mux 中添加路由:

mux := asynq.NewServeMux()
mux.Use(taskLoggingMiddleware)
mux.HandleFunc(task.CleanupUnusedUploadsTask, task.ProcessTask)
mux.HandleFunc(task.SendEmailTask, task.ProcessTask)

所有路由都指向同一个 task.ProcessTask,它内部根据 Asynq task type 查找对应的 handler 分发执行。

第 5 步(可选):添加 Cron 定时调度

如果任务需要定时执行,编辑 internal/task/scheduler/scheduler.go,在 StartScheduler() 中注册:

if _, err = scheduler.Register(
    config.Config.Scheduler.CleanupUnusedUploadsTaskCron,
    asynq.NewTask(task.CleanupUnusedUploadsTask, nil),
    asynq.Unique(23*time.Hour),
    asynq.MaxRetry(3),
); err != nil {
    return
}

然后在 internal/config/model.go 的 schedulerConfig 中添加字段:

type schedulerConfig struct {
    CleanupUnusedUploadsTaskCron string `mapstructure:"cleanup_unused_uploads_task_cron"`
    YourNewTaskCron              string `mapstructure:"your_new_task_cron"`
}

在 config.example.yaml 的 scheduler 段添加:

scheduler:
  cleanup_unused_uploads_task_cron: "0 */2 * * *"
  your_new_task_cron: "0 */6 * * *"

注意:通过 Scheduler 定时触发的任务不会创建 TaskExecution 记录(直接入队 Asynq)。ProcessTask 执行时若在数据库中找不到对应记录,会打印警告日志但仍会正常执行业务逻辑。如果需要状态追踪,应通过 Admin API 手动下发。

AppendLog 使用指南

task.AppendLog(ctx, format string, args ...interface{}) 在 TaskHandler.Execute 内部调用,将日志追加到 TaskExecution.Log 字段。管理端 API 可查看完整执行日志。

日志格式:每行自动添加 [HH:MM:SS] 时间前缀,使用 SQL COALESCE(log, '') || ? 拼接到 log 字段末尾。

何时记录日志:

时机 示例
任务开始时 task.AppendLog(ctx, "开始扫描,阈值: %s", threshold)
参数解析成功后 task.AppendLog(ctx, "开始发送邮件到: %s, 主题: %s", req.To, req.Subject)
批量处理的每一批次 task.AppendLog(ctx, "本批次处理 %d 条记录", len(batch))
关键中间状态 task.AppendLog(ctx, "已删除对象 %s", filePath)
遇到可继续的错误时 task.AppendLog(ctx, "清理文件失败 [ID:%d]: %v", id, err)
任务完成时 task.AppendLog(ctx, "共处理 %d 个,成功 %d 个", total, success)

降级行为:如果 ctx 中没有 taskID(比如 Scheduler 触发的任务没有数据库记录),AppendLog 会降级为 logger.InfoF 输出到应用日志,不会报错。

不要过度记录:避免在循环中对每条记录都追加日志(尤其是处理量很大的场景),可以在批次级别记录摘要。每个 AppendLog 都会执行一次数据库 UPDATE。

框架自动处理的事项

开发者不需要关心以下内容,全部由 ProcessTask 框架层透明处理:

  • 记录创建:DispatchTask 自动创建 TaskExecution 记录,生成唯一 TaskID(格式 {triggeredBy}_{taskType}_{snowflakeID})
  • 状态流转:pending → running → succeeded/failed
  • 耗时统计:自动记录 StartedAt、FinishedAt、Duration(毫秒)
  • 错误记录:失败时自动存储 ErrorMessage
  • 结果存储:成功时自动存储 TaskResult.Message 和 TaskResult.Detail
  • OTel 追踪:自动创建 Span,记录任务类型、Payload 大小、TaskID
  • 重试计数:手动重试时自动递增 RetryCount,校验 RetryCount < MaxRetry
  • 队列路由:根据 TaskMeta.Queue 投递到对应优先级队列
  • 前端表单渲染:ListTaskTypes API 返回 DispatchableTasks(含 Params),前端据此动态渲染参数表单

重试机制

项目中存在两层重试机制,互不干扰:

Asynq 自动重试:ProcessTask 返回 error 时,Asynq 根据入队时设置的 MaxRetry 自动重试。但 TaskExecution 记录在第一次失败时已标记为 failed,后续重试会覆盖同一条记录。

管理端手动重试:通过 POST /api/v1/admin/tasks/executions/{id}/retry 触发。会创建一个全新的 TaskExecution 记录(新 TaskID retry_{count}_{originalTaskID}),RetryCount 递增,Payload 原样复制。校验条件:状态必须为 failed、Retryable 为 true、RetryCount < MaxRetry。

参考:现有任务处理器

  • internal/apps/upload/tasks.go — CleanupUnusedUploadsHandler:无参数任务,展示游标分页批量处理、每批次 AppendLog、事务内操作、单条失败 continue 不终止。
  • internal/apps/user/tasks.go — SendEmailHandler:带参数任务,展示 Payload 结构体定义、json.Unmarshal 反序列化、参数日志记录。