mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 09:46:37 +08:00
async task framework
This commit is contained in:
@@ -95,7 +95,8 @@ internal/
|
|||||||
│ ├── users.go # User 实体、OAuthUserInfo、查询/更新方法
|
│ ├── users.go # User 实体、OAuthUserInfo、查询/更新方法
|
||||||
│ ├── auth_source.go # AuthSource 实体(OAuth 接入源)
|
│ ├── auth_source.go # AuthSource 实体(OAuth 接入源)
|
||||||
│ ├── system_configs.go # SystemConfig 实体(KV 系统配置)
|
│ ├── system_configs.go # SystemConfig 实体(KV 系统配置)
|
||||||
│ └── uploads.go # Upload 实体(上传文件记录)
|
│ ├── uploads.go # Upload 实体(上传文件记录)
|
||||||
|
│ └── task_execution.go # TaskExecution 实体(异步任务执行记录 + CRUD)
|
||||||
│
|
│
|
||||||
├── db/ # 数据库连接与基础设施
|
├── db/ # 数据库连接与基础设施
|
||||||
│ ├── postgres.go # PostgreSQL 初始化、读写分离、GORM 配置
|
│ ├── postgres.go # PostgreSQL 初始化、读写分离、GORM 配置
|
||||||
@@ -111,12 +112,14 @@ internal/
|
|||||||
│ └── errs.go # 存储层错误常量
|
│ └── errs.go # 存储层错误常量
|
||||||
│
|
│
|
||||||
├── task/ # 异步任务定义与调度
|
├── task/ # 异步任务定义与调度
|
||||||
│ ├── constants.go # 任务类型名称常量(TaskType)、队列名
|
│ ├── constants.go # 任务类型名称常量(TaskType)、队列名、TaskMeta(含 Retryable)
|
||||||
│ ├── utils.go # 任务工具函数(RedisOpt 等)
|
│ ├── handler.go # TaskHandler 接口定义 + TaskResult 结构体
|
||||||
|
│ ├── executor.go # 核心运行机制:RegisterHandler / DispatchTask / ProcessTask / RetryTask / AppendLog
|
||||||
|
│ ├── utils.go # 任务工具函数(RedisOpt、AsynqClient)
|
||||||
│ ├── scheduler/ # Asynq 定时任务调度器(Cron 注册)
|
│ ├── scheduler/ # Asynq 定时任务调度器(Cron 注册)
|
||||||
│ └── worker/ # Asynq Worker 服务端(任务处理器注册)
|
│ └── worker/ # Asynq Worker 服务端(任务处理器注册)
|
||||||
│ ├── worker.go # StartWorker 入口,注册 Handler
|
│ ├── worker.go # StartWorker 入口,注册 Handler
|
||||||
│ └── middlewares.go # Worker 中间件(日志等)
|
│ └── middlewares.go # Worker 中间件
|
||||||
│
|
│
|
||||||
├── service/ # 复杂业务逻辑服务层(当前占位,待填充)
|
├── service/ # 复杂业务逻辑服务层(当前占位,待填充)
|
||||||
│
|
│
|
||||||
@@ -234,6 +237,8 @@ components/common/
|
|||||||
├── admin/ # 管理员后台组件
|
├── admin/ # 管理员后台组件
|
||||||
│ ├── tasks.tsx # TaskManager — 异步任务调度管理页面,展示所有可用任务类型,
|
│ ├── tasks.tsx # TaskManager — 异步任务调度管理页面,展示所有可用任务类型,
|
||||||
│ │ # 支持通过弹窗配置参数后立即下发任务到后台队列执行
|
│ │ # 支持通过弹窗配置参数后立即下发任务到后台队列执行
|
||||||
|
│ ├── task-executions.tsx # TaskExecutionsManager — 任务日志页面,展示异步任务执行记录,
|
||||||
|
│ │ # 支持状态/类型筛选、分页、详情抽屉查看完整日志与失败任务重试
|
||||||
│ ├── system.tsx # SystemConfigs — 系统 KV 配置管理页面,以表格展示系统/业务两类
|
│ ├── system.tsx # SystemConfigs — 系统 KV 配置管理页面,以表格展示系统/业务两类
|
||||||
│ │ # 配置项,支持在线编辑(布尔类型自动渲染为 Switch)并保存/删除
|
│ │ # 配置项,支持在线编辑(布尔类型自动渲染为 Switch)并保存/删除
|
||||||
│ └── users.tsx # UsersManager — 用户管理页面,提供分页、搜索、筛选的用户列表表格,
|
│ └── users.tsx # UsersManager — 用户管理页面,提供分页、搜索、筛选的用户列表表格,
|
||||||
@@ -435,12 +440,37 @@ apps/admin/<module>/
|
|||||||
以新增 **异步任务** 为例:
|
以新增 **异步任务** 为例:
|
||||||
|
|
||||||
```
|
```
|
||||||
1. 在 internal/task/constants.go 定义任务类型常量
|
1. 在 internal/task/constants.go 定义任务类型常量 + TaskMeta(含 Retryable)
|
||||||
2. 在对应 apps/<module>/tasks.go 实现 Handle 函数
|
2. 在 apps/<module>/ 下创建 tasks.go,定义 struct 实现 TaskHandler 接口:
|
||||||
3. 在 internal/task/worker/worker.go 注册 Handler
|
type MyTaskHandler struct{}
|
||||||
4. 在 internal/task/scheduler/ 添加 Cron 调度(或 Admin API 手动触发)
|
func (h *MyTaskHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
||||||
5. 在 config.example.yaml 的 scheduler 段添加 Cron 配置项
|
task.AppendLog(ctx, "开始执行任务...")
|
||||||
6. 在 internal/config/model.go 添加配置字段
|
// ... 业务逻辑 ...
|
||||||
|
return &task.TaskResult{Message: "执行完成"}, nil
|
||||||
|
}
|
||||||
|
3. 在 internal/task/worker/worker.go 的 init() 中注册:
|
||||||
|
task.RegisterHandler(task.MyTask, &mymodule.MyTaskHandler{})
|
||||||
|
并在 StartWorker 的 mux 中添加路由:
|
||||||
|
mux.HandleFunc(task.MyTask, task.ProcessTask)
|
||||||
|
4.(可选)在 internal/task/scheduler/ 添加 Cron 调度
|
||||||
|
5.(可选)在 config.example.yaml 的 scheduler 段添加 Cron 配置项
|
||||||
|
6.(可选)在 internal/config/model.go 添加配置字段
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **框架运行机制**:开发者只需实现 `TaskHandler.Execute` 方法编写业务逻辑,通过 `task.AppendLog(ctx, ...)` 追加执行日志。任务的创建(`TaskExecution` 记录)、状态流转(pending → running → succeeded/failed)、日志写入、耗时统计、错误记录、重试计数全部由 `task.ProcessTask` 框架层透明处理。管理端可通过 API 查询执行记录、查看日志、手动重试失败任务。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、前端任务管理页面
|
||||||
|
|
||||||
|
任务管理 API 路由(Admin):
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/v1/admin/tasks/types` | 获取可调度任务类型列表 |
|
||||||
|
| POST | `/api/v1/admin/tasks/dispatch` | 手动下发任务 |
|
||||||
|
| GET | `/api/v1/admin/tasks/executions` | 分页查询任务执行记录(支持 status / task_type 筛选) |
|
||||||
|
| GET | `/api/v1/admin/tasks/executions/:id` | 查询单条任务执行详情(含完整 Log) |
|
||||||
|
| POST | `/api/v1/admin/tasks/executions/:id/retry` | 重试失败任务(校验 Retryable && RetryCount < MaxRetry) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+443
@@ -805,6 +805,229 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/admin/tasks/executions": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "分页查询任务执行记录,支持按状态和任务类型筛选,需要管理员权限",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
"summary": "查询任务执行记录",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "状态筛选 (pending/running/succeeded/failed)",
|
||||||
|
"name": "status",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "任务类型筛选",
|
||||||
|
"name": "task_type",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"default": 1,
|
||||||
|
"description": "页码",
|
||||||
|
"name": "page",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"default": 20,
|
||||||
|
"description": "每页条数",
|
||||||
|
"name": "page_size",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "任务执行记录列表",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "未登录",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "无管理员权限",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/tasks/executions/{id}": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "根据 ID 查询任务执行记录详情,包含完整执行日志,需要管理员权限",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
"summary": "查询任务执行详情",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "任务执行记录 ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "任务执行详情",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/definitions/model.TaskExecution"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "参数错误",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "未登录",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "无管理员权限",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "记录不存在",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/tasks/executions/{id}/retry": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "重新下发一条失败的任务,创建新的执行记录,需要管理员权限",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
"summary": "重试失败任务",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "任务执行记录 ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "新任务的 TaskID",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "任务不支持重试或参数错误",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "未登录",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "无管理员权限",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "记录不存在",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "重试失败",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/admin/tasks/types": {
|
"/api/v1/admin/tasks/types": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -1674,6 +1897,127 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/upload/my": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"upload"
|
||||||
|
],
|
||||||
|
"summary": "获取我的文件列表",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "页码(默认 1)",
|
||||||
|
"name": "page",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "每页数量(默认 20,最大 100)",
|
||||||
|
"name": "page_size",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件名关键词(模糊匹配)",
|
||||||
|
"name": "keyword",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "业务分类过滤",
|
||||||
|
"name": "type",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "扩展名过滤",
|
||||||
|
"name": "extension",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "查询成功",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/definitions/upload.listMyFilesResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "未登录",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/upload/{id}": {
|
||||||
|
"delete": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "将文件状态置为 deleted(软删除),不会立即清理底层存储对象",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"upload"
|
||||||
|
],
|
||||||
|
"summary": "删除文件",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件 ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "删除成功",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "无权操作",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "文件不存在",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/user/access-tokens": {
|
"/api/v1/user/access-tokens": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -2246,6 +2590,81 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"model.TaskExecution": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"created_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"error_message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"finished_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "0"
|
||||||
|
},
|
||||||
|
"log": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"max_retry": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"retry_count": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"retryable": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"$ref": "#/definitions/model.TaskExecutionStatus"
|
||||||
|
},
|
||||||
|
"task_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"task_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"task_type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"triggered_by": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model.TaskExecutionStatus": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"pending",
|
||||||
|
"running",
|
||||||
|
"succeeded",
|
||||||
|
"failed"
|
||||||
|
],
|
||||||
|
"x-enum-varnames": [
|
||||||
|
"TaskExecutionStatusPending",
|
||||||
|
"TaskExecutionStatusRunning",
|
||||||
|
"TaskExecutionStatusSucceeded",
|
||||||
|
"TaskExecutionStatusFailed"
|
||||||
|
]
|
||||||
|
},
|
||||||
"model.TrustLevel": {
|
"model.TrustLevel": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -2585,6 +3004,10 @@ const docTemplate = `{
|
|||||||
"queue": {
|
"queue": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"retryable": {
|
||||||
|
"description": "是否支持手动重试",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"supportsTime": {
|
"supportsTime": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -2608,6 +3031,26 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"upload.listMyFilesResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/model.Upload"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"page_size": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"user.createTokenRequest": {
|
"user.createTokenRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -798,6 +798,229 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/admin/tasks/executions": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "分页查询任务执行记录,支持按状态和任务类型筛选,需要管理员权限",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
"summary": "查询任务执行记录",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "状态筛选 (pending/running/succeeded/failed)",
|
||||||
|
"name": "status",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "任务类型筛选",
|
||||||
|
"name": "task_type",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"default": 1,
|
||||||
|
"description": "页码",
|
||||||
|
"name": "page",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"default": 20,
|
||||||
|
"description": "每页条数",
|
||||||
|
"name": "page_size",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "任务执行记录列表",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "未登录",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "无管理员权限",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/tasks/executions/{id}": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "根据 ID 查询任务执行记录详情,包含完整执行日志,需要管理员权限",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
"summary": "查询任务执行详情",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "任务执行记录 ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "任务执行详情",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/definitions/model.TaskExecution"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "参数错误",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "未登录",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "无管理员权限",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "记录不存在",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/tasks/executions/{id}/retry": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "重新下发一条失败的任务,创建新的执行记录,需要管理员权限",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
"summary": "重试失败任务",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "任务执行记录 ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "新任务的 TaskID",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "任务不支持重试或参数错误",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "未登录",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "无管理员权限",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "记录不存在",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "重试失败",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/admin/tasks/types": {
|
"/api/v1/admin/tasks/types": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -1667,6 +1890,127 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/upload/my": {
|
||||||
|
"get": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"upload"
|
||||||
|
],
|
||||||
|
"summary": "获取我的文件列表",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "页码(默认 1)",
|
||||||
|
"name": "page",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "每页数量(默认 20,最大 100)",
|
||||||
|
"name": "page_size",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件名关键词(模糊匹配)",
|
||||||
|
"name": "keyword",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "业务分类过滤",
|
||||||
|
"name": "type",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "扩展名过滤",
|
||||||
|
"name": "extension",
|
||||||
|
"in": "query"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "查询成功",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/definitions/upload.listMyFilesResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "未登录",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/upload/{id}": {
|
||||||
|
"delete": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "将文件状态置为 deleted(软删除),不会立即清理底层存储对象",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"upload"
|
||||||
|
],
|
||||||
|
"summary": "删除文件",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "文件 ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "删除成功",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "无权操作",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "文件不存在",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/util.ResponseAny"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/user/access-tokens": {
|
"/api/v1/user/access-tokens": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -2239,6 +2583,81 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"model.TaskExecution": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"created_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"error_message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"finished_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "0"
|
||||||
|
},
|
||||||
|
"log": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"max_retry": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"retry_count": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"retryable": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"$ref": "#/definitions/model.TaskExecutionStatus"
|
||||||
|
},
|
||||||
|
"task_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"task_name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"task_type": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"triggered_by": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model.TaskExecutionStatus": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"pending",
|
||||||
|
"running",
|
||||||
|
"succeeded",
|
||||||
|
"failed"
|
||||||
|
],
|
||||||
|
"x-enum-varnames": [
|
||||||
|
"TaskExecutionStatusPending",
|
||||||
|
"TaskExecutionStatusRunning",
|
||||||
|
"TaskExecutionStatusSucceeded",
|
||||||
|
"TaskExecutionStatusFailed"
|
||||||
|
]
|
||||||
|
},
|
||||||
"model.TrustLevel": {
|
"model.TrustLevel": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -2578,6 +2997,10 @@
|
|||||||
"queue": {
|
"queue": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"retryable": {
|
||||||
|
"description": "是否支持手动重试",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"supportsTime": {
|
"supportsTime": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -2601,6 +3024,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"upload.listMyFilesResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/model.Upload"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"page_size": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"user.createTokenRequest": {
|
"user.createTokenRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -104,6 +104,58 @@ definitions:
|
|||||||
value:
|
value:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
model.TaskExecution:
|
||||||
|
properties:
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
duration:
|
||||||
|
type: integer
|
||||||
|
error_message:
|
||||||
|
type: string
|
||||||
|
finished_at:
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
example: "0"
|
||||||
|
type: string
|
||||||
|
log:
|
||||||
|
type: string
|
||||||
|
max_retry:
|
||||||
|
type: integer
|
||||||
|
payload:
|
||||||
|
type: string
|
||||||
|
result:
|
||||||
|
type: string
|
||||||
|
retry_count:
|
||||||
|
type: integer
|
||||||
|
retryable:
|
||||||
|
type: boolean
|
||||||
|
started_at:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
$ref: '#/definitions/model.TaskExecutionStatus'
|
||||||
|
task_id:
|
||||||
|
type: string
|
||||||
|
task_name:
|
||||||
|
type: string
|
||||||
|
task_type:
|
||||||
|
type: string
|
||||||
|
triggered_by:
|
||||||
|
type: string
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
model.TaskExecutionStatus:
|
||||||
|
enum:
|
||||||
|
- pending
|
||||||
|
- running
|
||||||
|
- succeeded
|
||||||
|
- failed
|
||||||
|
type: string
|
||||||
|
x-enum-varnames:
|
||||||
|
- TaskExecutionStatusPending
|
||||||
|
- TaskExecutionStatusRunning
|
||||||
|
- TaskExecutionStatusSucceeded
|
||||||
|
- TaskExecutionStatusFailed
|
||||||
model.TrustLevel:
|
model.TrustLevel:
|
||||||
enum:
|
enum:
|
||||||
- 0
|
- 0
|
||||||
@@ -338,6 +390,9 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
queue:
|
queue:
|
||||||
type: string
|
type: string
|
||||||
|
retryable:
|
||||||
|
description: 是否支持手动重试
|
||||||
|
type: boolean
|
||||||
supportsTime:
|
supportsTime:
|
||||||
type: boolean
|
type: boolean
|
||||||
type:
|
type:
|
||||||
@@ -353,6 +408,19 @@ definitions:
|
|||||||
required:
|
required:
|
||||||
- ids
|
- ids
|
||||||
type: object
|
type: object
|
||||||
|
upload.listMyFilesResponse:
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/model.Upload'
|
||||||
|
type: array
|
||||||
|
page:
|
||||||
|
type: integer
|
||||||
|
page_size:
|
||||||
|
type: integer
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
user.createTokenRequest:
|
user.createTokenRequest:
|
||||||
properties:
|
properties:
|
||||||
name:
|
name:
|
||||||
@@ -923,6 +991,141 @@ paths:
|
|||||||
summary: 下发异步任务
|
summary: 下发异步任务
|
||||||
tags:
|
tags:
|
||||||
- admin
|
- admin
|
||||||
|
/api/v1/admin/tasks/executions:
|
||||||
|
get:
|
||||||
|
description: 分页查询任务执行记录,支持按状态和任务类型筛选,需要管理员权限
|
||||||
|
parameters:
|
||||||
|
- description: 状态筛选 (pending/running/succeeded/failed)
|
||||||
|
in: query
|
||||||
|
name: status
|
||||||
|
type: string
|
||||||
|
- description: 任务类型筛选
|
||||||
|
in: query
|
||||||
|
name: task_type
|
||||||
|
type: string
|
||||||
|
- default: 1
|
||||||
|
description: 页码
|
||||||
|
in: query
|
||||||
|
name: page
|
||||||
|
type: integer
|
||||||
|
- default: 20
|
||||||
|
description: 每页条数
|
||||||
|
in: query
|
||||||
|
name: page_size
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 任务执行记录列表
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/util.ResponseAny'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
"401":
|
||||||
|
description: 未登录
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
"403":
|
||||||
|
description: 无管理员权限
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 查询任务执行记录
|
||||||
|
tags:
|
||||||
|
- admin
|
||||||
|
/api/v1/admin/tasks/executions/{id}:
|
||||||
|
get:
|
||||||
|
description: 根据 ID 查询任务执行记录详情,包含完整执行日志,需要管理员权限
|
||||||
|
parameters:
|
||||||
|
- description: 任务执行记录 ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 任务执行详情
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/util.ResponseAny'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/model.TaskExecution'
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: 参数错误
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
"401":
|
||||||
|
description: 未登录
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
"403":
|
||||||
|
description: 无管理员权限
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
"404":
|
||||||
|
description: 记录不存在
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 查询任务执行详情
|
||||||
|
tags:
|
||||||
|
- admin
|
||||||
|
/api/v1/admin/tasks/executions/{id}/retry:
|
||||||
|
post:
|
||||||
|
description: 重新下发一条失败的任务,创建新的执行记录,需要管理员权限
|
||||||
|
parameters:
|
||||||
|
- description: 任务执行记录 ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 新任务的 TaskID
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/util.ResponseAny'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: 任务不支持重试或参数错误
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
"401":
|
||||||
|
description: 未登录
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
"403":
|
||||||
|
description: 无管理员权限
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
"404":
|
||||||
|
description: 记录不存在
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
"500":
|
||||||
|
description: 重试失败
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 重试失败任务
|
||||||
|
tags:
|
||||||
|
- admin
|
||||||
/api/v1/admin/tasks/types:
|
/api/v1/admin/tasks/types:
|
||||||
get:
|
get:
|
||||||
description: 返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限
|
description: 返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限
|
||||||
@@ -1378,6 +1581,35 @@ paths:
|
|||||||
summary: 上传文件
|
summary: 上传文件
|
||||||
tags:
|
tags:
|
||||||
- upload
|
- upload
|
||||||
|
/api/v1/upload/{id}:
|
||||||
|
delete:
|
||||||
|
description: 将文件状态置为 deleted(软删除),不会立即清理底层存储对象
|
||||||
|
parameters:
|
||||||
|
- description: 文件 ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 删除成功
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
"403":
|
||||||
|
description: 无权操作
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
"404":
|
||||||
|
description: 文件不存在
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 删除文件
|
||||||
|
tags:
|
||||||
|
- upload
|
||||||
/api/v1/upload/download/{id}:
|
/api/v1/upload/download/{id}:
|
||||||
get:
|
get:
|
||||||
description: 根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载
|
description: 根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载
|
||||||
@@ -1443,6 +1675,51 @@ paths:
|
|||||||
summary: 批量打包下载
|
summary: 批量打包下载
|
||||||
tags:
|
tags:
|
||||||
- upload
|
- upload
|
||||||
|
/api/v1/upload/my:
|
||||||
|
get:
|
||||||
|
description: 分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤
|
||||||
|
parameters:
|
||||||
|
- description: 页码(默认 1)
|
||||||
|
in: query
|
||||||
|
name: page
|
||||||
|
type: integer
|
||||||
|
- description: 每页数量(默认 20,最大 100)
|
||||||
|
in: query
|
||||||
|
name: page_size
|
||||||
|
type: integer
|
||||||
|
- description: 文件名关键词(模糊匹配)
|
||||||
|
in: query
|
||||||
|
name: keyword
|
||||||
|
type: string
|
||||||
|
- description: 业务分类过滤
|
||||||
|
in: query
|
||||||
|
name: type
|
||||||
|
type: string
|
||||||
|
- description: 扩展名过滤
|
||||||
|
in: query
|
||||||
|
name: extension
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 查询成功
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/util.ResponseAny'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/upload.listMyFilesResponse'
|
||||||
|
type: object
|
||||||
|
"401":
|
||||||
|
description: 未登录
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/util.ResponseAny'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 获取我的文件列表
|
||||||
|
tags:
|
||||||
|
- upload
|
||||||
/api/v1/user/access-tokens:
|
/api/v1/user/access-tokens:
|
||||||
get:
|
get:
|
||||||
description: 返回当前登录用户的所有 active access tokens(脱敏后)
|
description: 返回当前登录用户的所有 active access tokens(脱敏后)
|
||||||
|
|||||||
@@ -1,30 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect } from "react"
|
import {SystemConfigs} from "@/components/common/admin/system"
|
||||||
import { SystemConfigs } from "@/components/common/admin/system"
|
import {AdminProvider} from "@/contexts/admin-context"
|
||||||
import { useUser } from "@/contexts/user-context"
|
|
||||||
import { AdminProvider, useAdmin } from "@/contexts/admin-context"
|
|
||||||
|
|
||||||
/* 系统配置页面 */
|
/* 系统配置页面 */
|
||||||
export default function SystemConfigPage() {
|
export default function SystemConfigPage() {
|
||||||
return (
|
return (
|
||||||
<AdminProvider>
|
<AdminProvider>
|
||||||
<SystemConfigPageContent />
|
<SystemConfigs />
|
||||||
</AdminProvider>
|
</AdminProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 系统配置页面内容 */
|
|
||||||
function SystemConfigPageContent() {
|
|
||||||
const { user } = useUser()
|
|
||||||
const { refetchSystemConfigs } = useAdmin()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (user?.is_admin) {
|
|
||||||
refetchSystemConfigs()
|
|
||||||
}
|
|
||||||
}, [user?.is_admin, refetchSystemConfigs])
|
|
||||||
|
|
||||||
return <SystemConfigs />
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {TaskExecutionsManager} from "@/components/common/admin/task-executions"
|
||||||
|
|
||||||
|
/* 任务日志页面 */
|
||||||
|
export default function TaskExecutionsPage() {
|
||||||
|
return <TaskExecutionsManager />
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {useCallback, useEffect, useState} from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import {toast} from "sonner"
|
||||||
|
import {format} from "date-fns"
|
||||||
|
import {Activity, ChevronLeft, ChevronRight, Layers, RefreshCw, RotateCcw} from "lucide-react"
|
||||||
|
|
||||||
|
import {AdminService, TaskExecution, TaskExecutionStatus, TaskMeta} from "@/lib/services"
|
||||||
|
import {ErrorInline} from "@/components/layout/error"
|
||||||
|
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||||
|
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||||
|
import {Badge} from "@/components/ui/badge"
|
||||||
|
import {Button} from "@/components/ui/button"
|
||||||
|
import {Label} from "@/components/ui/label"
|
||||||
|
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||||
|
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle} from "@/components/ui/sheet"
|
||||||
|
import {Spinner} from "@/components/ui/spinner"
|
||||||
|
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<TaskExecutionStatus, string> = {
|
||||||
|
pending: "等待中",
|
||||||
|
running: "执行中",
|
||||||
|
succeeded: "成功",
|
||||||
|
failed: "失败",
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRIGGER_LABELS: Record<string, string> = {
|
||||||
|
system: "系统",
|
||||||
|
manual: "手动",
|
||||||
|
retry: "重试",
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string | null) {
|
||||||
|
if (!value) return "-"
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
return format(date, "yyyy-MM-dd HH:mm:ss")
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(duration: number) {
|
||||||
|
if (!duration) return "-"
|
||||||
|
if (duration < 1000) return `${ duration }ms`
|
||||||
|
return `${ (duration / 1000).toFixed(2) }s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusVariant(status: TaskExecutionStatus) {
|
||||||
|
if (status === "failed") return "destructive"
|
||||||
|
if (status === "succeeded") return "secondary"
|
||||||
|
return "outline"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskExecutionsManager() {
|
||||||
|
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
|
||||||
|
const [executionsLoading, setExecutionsLoading] = useState(false)
|
||||||
|
const [executionsError, setExecutionsError] = useState<Error | null>(null)
|
||||||
|
const [executions, setExecutions] = useState<TaskExecution[]>([])
|
||||||
|
const [executionsTotal, setExecutionsTotal] = useState(0)
|
||||||
|
const [executionsPage, setExecutionsPage] = useState(1)
|
||||||
|
const [executionStatus, setExecutionStatus] = useState<TaskExecutionStatus | "all">("all")
|
||||||
|
const [executionTaskType, setExecutionTaskType] = useState<string>("all")
|
||||||
|
const [selectedExecution, setSelectedExecution] = useState<TaskExecution | null>(null)
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
|
const [retrying, setRetrying] = useState(false)
|
||||||
|
|
||||||
|
const fetchTaskTypes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await AdminService.getTaskTypes()
|
||||||
|
setTaskTypes(data)
|
||||||
|
} catch {
|
||||||
|
setTaskTypes([])
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchTaskExecutions = useCallback(async (overrides?: {
|
||||||
|
page?: number
|
||||||
|
status?: TaskExecutionStatus | "all"
|
||||||
|
taskType?: string
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
setExecutionsLoading(true)
|
||||||
|
setExecutionsError(null)
|
||||||
|
const page = overrides?.page ?? executionsPage
|
||||||
|
const status = overrides?.status ?? executionStatus
|
||||||
|
const taskType = overrides?.taskType ?? executionTaskType
|
||||||
|
const data = await AdminService.listTaskExecutions({
|
||||||
|
page,
|
||||||
|
page_size: 10,
|
||||||
|
status: status === "all" ? undefined : status,
|
||||||
|
task_type: taskType === "all" ? undefined : taskType,
|
||||||
|
})
|
||||||
|
setExecutions(data.items || [])
|
||||||
|
setExecutionsTotal(data.total || 0)
|
||||||
|
} catch (err) {
|
||||||
|
setExecutionsError(err instanceof Error ? err : new Error("加载任务执行记录失败"))
|
||||||
|
} finally {
|
||||||
|
setExecutionsLoading(false)
|
||||||
|
}
|
||||||
|
}, [executionStatus, executionTaskType, executionsPage])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTaskTypes()
|
||||||
|
}, [fetchTaskTypes])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTaskExecutions()
|
||||||
|
}, [fetchTaskExecutions])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExecutionsPage(1)
|
||||||
|
}, [executionStatus, executionTaskType])
|
||||||
|
|
||||||
|
const openExecutionDetail = async (execution: TaskExecution) => {
|
||||||
|
setSelectedExecution(execution)
|
||||||
|
setDetailOpen(true)
|
||||||
|
try {
|
||||||
|
setDetailLoading(true)
|
||||||
|
const detail = await AdminService.getTaskExecution(execution.id)
|
||||||
|
setSelectedExecution(detail)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("任务详情加载失败", {
|
||||||
|
description: err instanceof Error ? err.message : "未知错误",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRetryExecution = async () => {
|
||||||
|
if (!selectedExecution) return
|
||||||
|
try {
|
||||||
|
setRetrying(true)
|
||||||
|
const taskID = await AdminService.retryTaskExecution(selectedExecution.id)
|
||||||
|
toast.success("任务已重新下发", {
|
||||||
|
description: `新任务 ID:${ taskID }`,
|
||||||
|
})
|
||||||
|
await fetchTaskExecutions()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("任务重试失败", {
|
||||||
|
description: err instanceof Error ? err.message : "未知错误",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setRetrying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(executionsTotal / 10))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-6 space-y-6">
|
||||||
|
<div className="flex flex-col gap-3 border-b border-border pb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Activity className="size-5 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-semibold tracking-tight">任务日志</div>
|
||||||
|
<div className="text-xs text-muted-foreground">查看异步任务执行状况,点击任务记录查看详情</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link href="/admin/tasks">
|
||||||
|
<Layers className="size-4" />
|
||||||
|
任务管理
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Select value={executionStatus} onValueChange={(value) => setExecutionStatus(value as TaskExecutionStatus | "all")}>
|
||||||
|
<SelectTrigger size="sm" className="w-[120px]">
|
||||||
|
<SelectValue placeholder="状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
<SelectItem value="pending">等待中</SelectItem>
|
||||||
|
<SelectItem value="running">执行中</SelectItem>
|
||||||
|
<SelectItem value="succeeded">成功</SelectItem>
|
||||||
|
<SelectItem value="failed">失败</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={executionTaskType} onValueChange={setExecutionTaskType}>
|
||||||
|
<SelectTrigger size="sm" className="w-[180px]">
|
||||||
|
<SelectValue placeholder="任务类型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部任务</SelectItem>
|
||||||
|
{taskTypes.map((task) => (
|
||||||
|
<SelectItem key={task.type} value={task.type}>{task.name || task.type}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => fetchTaskExecutions()} disabled={executionsLoading}>
|
||||||
|
{executionsLoading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{executionsError ? (
|
||||||
|
<div className="p-8 border border-dashed rounded-lg bg-card">
|
||||||
|
<ErrorInline error={executionsError} onRetry={fetchTaskExecutions} className="justify-center" />
|
||||||
|
</div>
|
||||||
|
) : executionsLoading && executions.length === 0 ? (
|
||||||
|
<LoadingStateWithBorder icon={Activity} description="加载任务执行记录中..." />
|
||||||
|
) : executions.length === 0 ? (
|
||||||
|
<EmptyStateWithBorder icon={Activity} description="暂无任务执行记录" />
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
<Table className="min-w-[900px]">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="w-[180px]">任务</TableHead>
|
||||||
|
<TableHead className="w-[100px]">状态</TableHead>
|
||||||
|
<TableHead className="w-[110px]">触发</TableHead>
|
||||||
|
<TableHead className="w-[120px]">重试</TableHead>
|
||||||
|
<TableHead className="w-[120px]">耗时</TableHead>
|
||||||
|
<TableHead className="min-w-[220px]">结果/错误</TableHead>
|
||||||
|
<TableHead className="w-[170px]">创建时间</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{executions.map((execution) => (
|
||||||
|
<TableRow
|
||||||
|
key={execution.id}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => openExecutionDetail(execution)}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-medium">{execution.task_name || execution.task_type}</span>
|
||||||
|
<span className="font-mono text-[11px] text-muted-foreground">{execution.task_id}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusVariant(execution.status)}>
|
||||||
|
{STATUS_LABELS[execution.status] || execution.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{TRIGGER_LABELS[execution.triggered_by] || execution.triggered_by}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{execution.retry_count}/{execution.max_retry}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatDuration(execution.duration)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[320px] truncate text-xs text-muted-foreground">
|
||||||
|
{execution.error_message || execution.result || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-[11px] text-muted-foreground">
|
||||||
|
{formatDateTime(execution.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
共 {executionsTotal} 条,当前第 {executionsPage}/{totalPages} 页
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setExecutionsPage((page) => Math.max(1, page - 1))}
|
||||||
|
disabled={executionsPage <= 1 || executionsLoading}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setExecutionsPage((page) => Math.min(totalPages, page + 1))}
|
||||||
|
disabled={executionsPage >= totalPages || executionsLoading}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
|
||||||
|
<SheetContent className="w-full p-0 sm:max-w-[640px]">
|
||||||
|
<SheetHeader className="border-b">
|
||||||
|
<SheetTitle>任务执行详情</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
{selectedExecution?.task_name || selectedExecution?.task_type || "任务记录"}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 pb-4">
|
||||||
|
{detailLoading && !selectedExecution ? (
|
||||||
|
<LoadingStateWithBorder icon={Activity} description="加载任务详情中..." />
|
||||||
|
) : selectedExecution ? (
|
||||||
|
<div className="space-y-5 py-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">状态</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<Badge variant={statusVariant(selectedExecution.status)}>
|
||||||
|
{STATUS_LABELS[selectedExecution.status] || selectedExecution.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">触发来源</div>
|
||||||
|
<div className="mt-2 text-sm font-medium">
|
||||||
|
{TRIGGER_LABELS[selectedExecution.triggered_by] || selectedExecution.triggered_by}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">重试次数</div>
|
||||||
|
<div className="mt-2 font-mono text-sm">
|
||||||
|
{selectedExecution.retry_count}/{selectedExecution.max_retry}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<div className="text-xs text-muted-foreground">耗时</div>
|
||||||
|
<div className="mt-2 font-mono text-sm">
|
||||||
|
{formatDuration(selectedExecution.duration)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>任务标识</Label>
|
||||||
|
<div className="rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all">
|
||||||
|
{selectedExecution.task_id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>创建时间</Label>
|
||||||
|
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>开始时间</Label>
|
||||||
|
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.started_at)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>结束时间</Label>
|
||||||
|
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.finished_at)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>更新时间</Label>
|
||||||
|
<div className="font-mono text-xs text-muted-foreground">{formatDateTime(selectedExecution.updated_at)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>执行结果</Label>
|
||||||
|
<div className="min-h-10 rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
||||||
|
{selectedExecution.result || "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedExecution.error_message && (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>错误信息</Label>
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||||
|
{selectedExecution.error_message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Payload</Label>
|
||||||
|
<pre className="max-h-40 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed">
|
||||||
|
{selectedExecution.payload || "{}"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>执行日志</Label>
|
||||||
|
<pre className="min-h-48 max-h-[420px] overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed whitespace-pre-wrap">
|
||||||
|
{selectedExecution.log || "暂无日志"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyStateWithBorder icon={Activity} description="未选择任务记录" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SheetFooter className="border-t">
|
||||||
|
<Button variant="outline" onClick={() => selectedExecution && openExecutionDetail(selectedExecution)} disabled={!selectedExecution || detailLoading}>
|
||||||
|
{detailLoading ? <Spinner className="size-4" /> : <RefreshCw className="size-4" />}
|
||||||
|
刷新详情
|
||||||
|
</Button>
|
||||||
|
{selectedExecution?.status === "failed" && selectedExecution.retryable && selectedExecution.retry_count < selectedExecution.max_retry && (
|
||||||
|
<Button onClick={handleRetryExecution} disabled={retrying}>
|
||||||
|
{retrying ? <Spinner className="size-4" /> : <RotateCcw className="size-4" />}
|
||||||
|
重试任务
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,30 +1,27 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react"
|
import {useCallback, useEffect, useState} from "react"
|
||||||
import { toast } from "sonner"
|
import Link from "next/link"
|
||||||
import { Button } from "@/components/ui/button"
|
import {useRouter} from "next/navigation"
|
||||||
import { Input } from "@/components/ui/input"
|
import {toast} from "sonner"
|
||||||
import { Label } from "@/components/ui/label"
|
import {Button} from "@/components/ui/button"
|
||||||
import { Spinner } from "@/components/ui/spinner"
|
import {Input} from "@/components/ui/input"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import {Label} from "@/components/ui/label"
|
||||||
import { Layers, Play, Clock, Info } from "lucide-react"
|
import {Spinner} from "@/components/ui/spinner"
|
||||||
|
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||||
|
import {Calendar as CalendarIcon, Clock, FileText, Info, Layers, Play} from "lucide-react"
|
||||||
|
|
||||||
import { TaskMeta, AdminService, DispatchTaskRequest } from "@/lib/services"
|
import {AdminService, DispatchTaskRequest, TaskMeta} from "@/lib/services"
|
||||||
import { ErrorInline } from "@/components/layout/error"
|
import {ErrorInline} from "@/components/layout/error"
|
||||||
import { LoadingStateWithBorder } from "@/components/layout/loading"
|
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||||
import { EmptyStateWithBorder } from "@/components/layout/empty"
|
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import {Badge} from "@/components/ui/badge"
|
||||||
import { cn } from "@/lib/utils"
|
import {cn} from "@/lib/utils"
|
||||||
|
|
||||||
import { format } from "date-fns"
|
import {format} from "date-fns"
|
||||||
import { zhCN } from "date-fns/locale"
|
import {zhCN} from "date-fns/locale"
|
||||||
import { Calendar as CalendarIcon } from "lucide-react"
|
import {Calendar} from "@/components/ui/calendar"
|
||||||
import { Calendar } from "@/components/ui/calendar"
|
import {Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover"
|
|
||||||
|
|
||||||
const TASK_CONFIGS: Record<string, { icon: React.ComponentType<{ className?: string }>, color: string, gradient: string }> = {
|
const TASK_CONFIGS: Record<string, { icon: React.ComponentType<{ className?: string }>, color: string, gradient: string }> = {
|
||||||
'order_sync': {
|
'order_sync': {
|
||||||
@@ -133,6 +130,7 @@ function DatePickerWithTime({ date, setDate }: { date: Date | undefined, setDate
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TaskManager() {
|
export function TaskManager() {
|
||||||
|
const router = useRouter()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<Error | null>(null)
|
const [error, setError] = useState<Error | null>(null)
|
||||||
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
|
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
|
||||||
@@ -183,16 +181,17 @@ export function TaskManager() {
|
|||||||
if (userId) params.user_id = userId
|
if (userId) params.user_id = userId
|
||||||
}
|
}
|
||||||
|
|
||||||
await AdminService.dispatchTask(params)
|
const taskID = await AdminService.dispatchTask(params)
|
||||||
|
|
||||||
toast.success('任务下发成功', {
|
toast.success('任务下发成功', {
|
||||||
description: `已成功将任务 ${ targetTask?.name || selectedTaskType } 加入队列`
|
description: `已成功将任务 ${ targetTask?.name || selectedTaskType } 加入队列:${ taskID }`
|
||||||
})
|
})
|
||||||
setDialogOpen(false)
|
setDialogOpen(false)
|
||||||
|
|
||||||
setStartTime(undefined)
|
setStartTime(undefined)
|
||||||
setEndTime(undefined)
|
setEndTime(undefined)
|
||||||
setUserId("")
|
setUserId("")
|
||||||
|
router.push("/admin/tasks/executions")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error('任务下发失败', {
|
toast.error('任务下发失败', {
|
||||||
description: err instanceof Error ? err.message : '未知错误'
|
description: err instanceof Error ? err.message : '未知错误'
|
||||||
@@ -217,6 +216,12 @@ export function TaskManager() {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="text-2xl font-semibold tracking-tight">任务管理</div>
|
<div className="text-2xl font-semibold tracking-tight">任务管理</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link href="/admin/tasks/executions">
|
||||||
|
<FileText className="size-4" />
|
||||||
|
任务日志
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ import type {
|
|||||||
AuthSourceRequest,
|
AuthSourceRequest,
|
||||||
CreateSystemConfigRequest,
|
CreateSystemConfigRequest,
|
||||||
DispatchTaskRequest,
|
DispatchTaskRequest,
|
||||||
|
ListTaskExecutionsRequest,
|
||||||
|
ListTaskExecutionsResponse,
|
||||||
ListUsersRequest,
|
ListUsersRequest,
|
||||||
ListUsersResponse,
|
ListUsersResponse,
|
||||||
SystemConfig,
|
SystemConfig,
|
||||||
|
TaskExecution,
|
||||||
TaskMeta,
|
TaskMeta,
|
||||||
TaskTypeResponse,
|
TaskTypeResponse,
|
||||||
ToggleAuthSourceRequest,
|
ToggleAuthSourceRequest,
|
||||||
@@ -215,8 +218,34 @@ export class AdminService extends BaseService {
|
|||||||
* - user_gamification 需要 user_id 参数
|
* - user_gamification 需要 user_id 参数
|
||||||
* - 其他任务无需额外参数
|
* - 其他任务无需额外参数
|
||||||
*/
|
*/
|
||||||
static async dispatchTask(request: DispatchTaskRequest): Promise<void> {
|
static async dispatchTask(request: DispatchTaskRequest): Promise<string> {
|
||||||
return this.post<void>('/tasks/dispatch', request);
|
return this.post<string>('/tasks/dispatch', request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询任务执行记录列表
|
||||||
|
*/
|
||||||
|
static async listTaskExecutions(
|
||||||
|
request: ListTaskExecutionsRequest = {},
|
||||||
|
): Promise<ListTaskExecutionsResponse> {
|
||||||
|
return this.get<ListTaskExecutionsResponse>(
|
||||||
|
'/tasks/executions',
|
||||||
|
request as unknown as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询任务执行详情
|
||||||
|
*/
|
||||||
|
static async getTaskExecution(id: string): Promise<TaskExecution> {
|
||||||
|
return this.get<TaskExecution>(`/tasks/executions/${ id }`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试失败任务
|
||||||
|
*/
|
||||||
|
static async retryTaskExecution(id: string): Promise<string> {
|
||||||
|
return this.post<string>(`/tasks/executions/${ id }/retry`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 用户管理 ====================
|
// ==================== 用户管理 ====================
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export type {
|
|||||||
AuthSourceRequest,
|
AuthSourceRequest,
|
||||||
ToggleAuthSourceRequest,
|
ToggleAuthSourceRequest,
|
||||||
TaskMeta,
|
TaskMeta,
|
||||||
|
TaskExecution,
|
||||||
|
TaskExecutionStatus,
|
||||||
|
ListTaskExecutionsRequest,
|
||||||
|
ListTaskExecutionsResponse,
|
||||||
DispatchTaskRequest,
|
DispatchTaskRequest,
|
||||||
AdminUser,
|
AdminUser,
|
||||||
ListUsersRequest,
|
ListUsersRequest,
|
||||||
|
|||||||
@@ -100,6 +100,52 @@ export interface DispatchTaskRequest {
|
|||||||
user_id?: string;
|
user_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TaskExecutionStatus = 'pending' | 'running' | 'succeeded' | 'failed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务执行记录
|
||||||
|
*/
|
||||||
|
export interface TaskExecution {
|
||||||
|
id: string;
|
||||||
|
task_id: string;
|
||||||
|
task_type: string;
|
||||||
|
task_name: string;
|
||||||
|
status: TaskExecutionStatus;
|
||||||
|
retryable: boolean;
|
||||||
|
max_retry: number;
|
||||||
|
retry_count: number;
|
||||||
|
log: string;
|
||||||
|
error_message: string;
|
||||||
|
result: string;
|
||||||
|
started_at?: string | null;
|
||||||
|
finished_at?: string | null;
|
||||||
|
duration: number;
|
||||||
|
payload: string;
|
||||||
|
triggered_by: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询任务执行记录请求参数
|
||||||
|
*/
|
||||||
|
export interface ListTaskExecutionsRequest {
|
||||||
|
status?: TaskExecutionStatus;
|
||||||
|
task_type?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询任务执行记录响应
|
||||||
|
*/
|
||||||
|
export interface ListTaskExecutionsResponse {
|
||||||
|
items: TaskExecution[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 用户管理 ====================
|
// ==================== 用户管理 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ export type {
|
|||||||
CreateSystemConfigRequest,
|
CreateSystemConfigRequest,
|
||||||
UpdateSystemConfigRequest,
|
UpdateSystemConfigRequest,
|
||||||
TaskMeta,
|
TaskMeta,
|
||||||
|
TaskExecution,
|
||||||
|
TaskExecutionStatus,
|
||||||
|
ListTaskExecutionsRequest,
|
||||||
|
ListTaskExecutionsResponse,
|
||||||
DispatchTaskRequest,
|
DispatchTaskRequest,
|
||||||
AdminUser,
|
AdminUser,
|
||||||
ListUsersRequest,
|
ListUsersRequest,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ go 1.25.5
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/ClickHouse/clickhouse-go/v2 v2.37.2
|
github.com/ClickHouse/clickhouse-go/v2 v2.37.2
|
||||||
|
github.com/alicebob/miniredis/v2 v2.38.0
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.14
|
github.com/aws/aws-sdk-go-v2/config v1.32.14
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
|
||||||
@@ -12,6 +13,8 @@ require (
|
|||||||
github.com/coreos/go-oidc/v3 v3.17.0
|
github.com/coreos/go-oidc/v3 v3.17.0
|
||||||
github.com/gin-contrib/sessions v1.0.4
|
github.com/gin-contrib/sessions v1.0.4
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
|
github.com/glebarez/sqlite v1.11.0
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.3
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/hibiken/asynq v0.25.1
|
github.com/hibiken/asynq v0.25.1
|
||||||
github.com/redis/go-redis/extra/redisotel/v9 v9.16.0
|
github.com/redis/go-redis/extra/redisotel/v9 v9.16.0
|
||||||
@@ -19,6 +22,7 @@ require (
|
|||||||
github.com/shopspring/decimal v1.4.0
|
github.com/shopspring/decimal v1.4.0
|
||||||
github.com/spf13/cobra v1.10.1
|
github.com/spf13/cobra v1.10.1
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/swaggo/files v1.0.1
|
github.com/swaggo/files v1.0.1
|
||||||
github.com/swaggo/gin-swagger v1.6.1
|
github.com/swaggo/gin-swagger v1.6.1
|
||||||
github.com/swaggo/swag v1.16.6
|
github.com/swaggo/swag v1.16.6
|
||||||
@@ -30,10 +34,12 @@ require (
|
|||||||
go.opentelemetry.io/otel/sdk v1.36.0
|
go.opentelemetry.io/otel/sdk v1.36.0
|
||||||
go.opentelemetry.io/otel/trace v1.36.0
|
go.opentelemetry.io/otel/trace v1.36.0
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
|
golang.org/x/crypto v0.47.0
|
||||||
golang.org/x/oauth2 v0.32.0
|
golang.org/x/oauth2 v0.32.0
|
||||||
golang.org/x/sync v0.19.0
|
golang.org/x/sync v0.19.0
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
gorm.io/plugin/dbresolver v1.6.2
|
gorm.io/plugin/dbresolver v1.6.2
|
||||||
gorm.io/plugin/opentelemetry v0.1.14
|
gorm.io/plugin/opentelemetry v0.1.14
|
||||||
@@ -43,7 +49,6 @@ require (
|
|||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/ClickHouse/ch-go v0.66.1 // indirect
|
github.com/ClickHouse/ch-go v0.66.1 // indirect
|
||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
github.com/alicebob/miniredis/v2 v2.38.0 // indirect
|
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
|
||||||
@@ -67,6 +72,7 @@ require (
|
|||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
@@ -74,10 +80,8 @@ require (
|
|||||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
|
||||||
github.com/go-faster/city v1.0.1 // indirect
|
github.com/go-faster/city v1.0.1 // indirect
|
||||||
github.com/go-faster/errors v0.7.1 // indirect
|
github.com/go-faster/errors v0.7.1 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
github.com/go-openapi/jsonpointer v0.22.1 // indirect
|
||||||
@@ -121,6 +125,7 @@ require (
|
|||||||
github.com/paulmach/orb v0.12.0 // indirect
|
github.com/paulmach/orb v0.12.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.55.0 // indirect
|
github.com/quic-go/quic-go v0.55.0 // indirect
|
||||||
github.com/redis/go-redis/extra/rediscmd/v9 v9.16.0 // indirect
|
github.com/redis/go-redis/extra/rediscmd/v9 v9.16.0 // indirect
|
||||||
@@ -145,7 +150,6 @@ require (
|
|||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/crypto v0.47.0 // indirect
|
|
||||||
golang.org/x/mod v0.32.0 // indirect
|
golang.org/x/mod v0.32.0 // indirect
|
||||||
golang.org/x/net v0.49.0 // indirect
|
golang.org/x/net v0.49.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
@@ -159,7 +163,6 @@ require (
|
|||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gorm.io/driver/clickhouse v0.7.0 // indirect
|
gorm.io/driver/clickhouse v0.7.0 // indirect
|
||||||
gorm.io/driver/mysql v1.6.0 // indirect
|
gorm.io/driver/mysql v1.6.0 // indirect
|
||||||
gorm.io/driver/sqlite v1.6.0 // indirect
|
|
||||||
modernc.org/libc v1.22.5 // indirect
|
modernc.org/libc v1.22.5 // indirect
|
||||||
modernc.org/mathutil v1.5.0 // indirect
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
modernc.org/memory v1.5.0 // indirect
|
modernc.org/memory v1.5.0 // indirect
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
|
|||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2025 linux.do
|
Copyright 2025-2026 linux.do
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@@ -17,8 +17,13 @@ limitations under the License.
|
|||||||
package task
|
package task
|
||||||
|
|
||||||
const (
|
const (
|
||||||
InvalidTaskType = "无效的任务类型"
|
InvalidTaskType = "无效的任务类型"
|
||||||
InvalidTimeRange = "无效的时间范围"
|
InvalidTimeRange = "无效的时间范围"
|
||||||
TaskDispatchFailed = "任务下发失败"
|
TaskDispatchFailed = "任务下发失败"
|
||||||
UserIDRequired = "用户ID必填"
|
UserIDRequired = "用户ID必填"
|
||||||
|
TaskNotFound = "任务执行记录不存在"
|
||||||
|
TaskNotRetryable = "该任务不支持重试"
|
||||||
|
TaskNotFailed = "只有失败的任务才能重试"
|
||||||
|
TaskMaxRetryExceeded = "已达到最大重试次数"
|
||||||
|
TaskRetryFailed = "任务重试失败"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2025 linux.do
|
Copyright 2025-2026 linux.do
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@@ -19,12 +19,13 @@ package task
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/hibiken/asynq"
|
"github.com/linux-do/credit/internal/model"
|
||||||
"github.com/linux-do/credit/internal/task"
|
"github.com/linux-do/credit/internal/task"
|
||||||
"github.com/linux-do/credit/internal/task/scheduler"
|
|
||||||
"github.com/linux-do/credit/internal/util"
|
"github.com/linux-do/credit/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ type DispatchTaskRequest struct {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security SessionCookie
|
// @Security SessionCookie
|
||||||
// @Param request body task.DispatchTaskRequest true "任务请求参数"
|
// @Param request body DispatchTaskRequest true "任务请求参数"
|
||||||
// @Success 200 {object} util.ResponseAny{data=string} "任务已入队"
|
// @Success 200 {object} util.ResponseAny{data=string} "任务已入队"
|
||||||
// @Failure 400 {object} util.ResponseAny "任务类型不存在或参数错误"
|
// @Failure 400 {object} util.ResponseAny "任务类型不存在或参数错误"
|
||||||
// @Failure 401 {object} util.ResponseAny "未登录"
|
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||||
@@ -77,22 +78,113 @@ func DispatchTask(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var taskInfo *asynq.Task
|
taskID, err := task.DispatchTask(c.Request.Context(), req.TaskType, nil, "manual")
|
||||||
var taskID string
|
|
||||||
|
|
||||||
taskInfo = asynq.NewTask(meta.AsynqTask, nil)
|
|
||||||
taskID = fmt.Sprintf("manual_%s", req.TaskType)
|
|
||||||
|
|
||||||
_, err := scheduler.AsynqClient.Enqueue(
|
|
||||||
taskInfo,
|
|
||||||
asynq.TaskID(taskID),
|
|
||||||
asynq.MaxRetry(meta.MaxRetry),
|
|
||||||
asynq.Queue(meta.Queue),
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, util.Err(fmt.Sprintf("%s: %v", TaskDispatchFailed, err)))
|
c.JSON(http.StatusInternalServerError, util.Err(fmt.Sprintf("%s: %v", TaskDispatchFailed, err)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, util.OKNil())
|
c.JSON(http.StatusOK, util.OK(taskID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTaskExecutions 查询任务执行记录列表
|
||||||
|
// @Summary 查询任务执行记录
|
||||||
|
// @Description 分页查询任务执行记录,支持按状态和任务类型筛选,需要管理员权限
|
||||||
|
// @Tags admin
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param status query string false "状态筛选 (pending/running/succeeded/failed)"
|
||||||
|
// @Param task_type query string false "任务类型筛选"
|
||||||
|
// @Param page query int false "页码" default(1)
|
||||||
|
// @Param page_size query int false "每页条数" default(20)
|
||||||
|
// @Success 200 {object} util.ResponseAny{data=object} "任务执行记录列表"
|
||||||
|
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||||
|
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||||
|
// @Router /api/v1/admin/tasks/executions [get]
|
||||||
|
func ListTaskExecutions(c *gin.Context) {
|
||||||
|
var req model.ListTaskExecutionsRequest
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
executions, total, err := model.ListTaskExecutions(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, util.OK(gin.H{
|
||||||
|
"items": executions,
|
||||||
|
"total": total,
|
||||||
|
"page": req.Page,
|
||||||
|
"page_size": req.PageSize,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTaskExecution 查询单条任务执行详情
|
||||||
|
// @Summary 查询任务执行详情
|
||||||
|
// @Description 根据 ID 查询任务执行记录详情,包含完整执行日志,需要管理员权限
|
||||||
|
// @Tags admin
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param id path int true "任务执行记录 ID"
|
||||||
|
// @Success 200 {object} util.ResponseAny{data=model.TaskExecution} "任务执行详情"
|
||||||
|
// @Failure 400 {object} util.ResponseAny "参数错误"
|
||||||
|
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||||
|
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||||
|
// @Failure 404 {object} util.ResponseAny "记录不存在"
|
||||||
|
// @Router /api/v1/admin/tasks/executions/{id} [get]
|
||||||
|
func GetTaskExecution(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, util.Err("无效的任务执行记录 ID"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
execution, err := model.GetTaskExecutionByID(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, util.Err(TaskNotFound))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, util.OK(execution))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryTask 重试失败的任务
|
||||||
|
// @Summary 重试失败任务
|
||||||
|
// @Description 重新下发一条失败的任务,创建新的执行记录,需要管理员权限
|
||||||
|
// @Tags admin
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param id path int true "任务执行记录 ID"
|
||||||
|
// @Success 200 {object} util.ResponseAny{data=string} "新任务的 TaskID"
|
||||||
|
// @Failure 400 {object} util.ResponseAny "任务不支持重试或参数错误"
|
||||||
|
// @Failure 401 {object} util.ResponseAny "未登录"
|
||||||
|
// @Failure 403 {object} util.ResponseAny "无管理员权限"
|
||||||
|
// @Failure 404 {object} util.ResponseAny "记录不存在"
|
||||||
|
// @Failure 500 {object} util.ResponseAny "重试失败"
|
||||||
|
// @Router /api/v1/admin/tasks/executions/{id}/retry [post]
|
||||||
|
func RetryTask(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, util.Err("无效的任务执行记录 ID"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newTaskID, err := task.RetryTask(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
errMsg := err.Error()
|
||||||
|
switch {
|
||||||
|
case strings.Contains(errMsg, "不存在"):
|
||||||
|
c.JSON(http.StatusNotFound, util.Err(errMsg))
|
||||||
|
case strings.Contains(errMsg, "只有失败") || strings.Contains(errMsg, "不支持重试") || strings.Contains(errMsg, "已达到最大重试"):
|
||||||
|
c.JSON(http.StatusBadRequest, util.Err(errMsg))
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, util.Err(fmt.Sprintf("%s: %v", TaskRetryFailed, err)))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, util.OK(newTaskID))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ package task
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/linux-do/credit/internal/apps/oauth"
|
"github.com/linux-do/credit/internal/apps/oauth"
|
||||||
@@ -29,6 +32,8 @@ import (
|
|||||||
"github.com/linux-do/credit/internal/task"
|
"github.com/linux-do/credit/internal/task"
|
||||||
"github.com/linux-do/credit/internal/testhelper"
|
"github.com/linux-do/credit/internal/testhelper"
|
||||||
"github.com/linux-do/credit/internal/util"
|
"github.com/linux-do/credit/internal/util"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupTestRouter(authUser *model.User) *gin.Engine {
|
func setupTestRouter(authUser *model.User) *gin.Engine {
|
||||||
@@ -46,6 +51,9 @@ func setupTestRouter(authUser *model.User) *gin.Engine {
|
|||||||
|
|
||||||
adminGroup.GET("/tasks/types", ListTaskTypes)
|
adminGroup.GET("/tasks/types", ListTaskTypes)
|
||||||
adminGroup.POST("/tasks/dispatch", DispatchTask)
|
adminGroup.POST("/tasks/dispatch", DispatchTask)
|
||||||
|
adminGroup.GET("/tasks/executions", ListTaskExecutions)
|
||||||
|
adminGroup.GET("/tasks/executions/:id", GetTaskExecution)
|
||||||
|
adminGroup.POST("/tasks/executions/:id/retry", RetryTask)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,9 +112,17 @@ func TestDispatchTask(t *testing.T) {
|
|||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
router.ServeHTTP(w, req)
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
assert.Equal(t, http.StatusOK, w.Code, "Body: %s", w.Body.String())
|
||||||
t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
|
|
||||||
}
|
var resp util.ResponseAny
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
assert.Empty(t, resp.ErrorMsg)
|
||||||
|
assert.NotNil(t, resp.Data)
|
||||||
|
|
||||||
|
// 返回的 data 应该是 taskID
|
||||||
|
taskID, ok := resp.Data.(string)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.NotEmpty(t, taskID)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("dispatch invalid task type failure", func(t *testing.T) {
|
t.Run("dispatch invalid task type failure", func(t *testing.T) {
|
||||||
@@ -119,14 +135,307 @@ func TestDispatchTask(t *testing.T) {
|
|||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
router.ServeHTTP(w, req)
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
t.Errorf("expected 400 Bad Request, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp util.ResponseAny
|
var resp util.ResponseAny
|
||||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
if resp.ErrorMsg != InvalidTaskType {
|
assert.Equal(t, InvalidTaskType, resp.ErrorMsg)
|
||||||
t.Errorf("expected error message '%s', got '%s'", InvalidTaskType, resp.ErrorMsg)
|
})
|
||||||
}
|
|
||||||
|
t.Run("dispatch with empty body failure", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/dispatch", bytes.NewBuffer([]byte("{}")))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListTaskExecutions(t *testing.T) {
|
||||||
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
|
||||||
|
router := setupTestRouter(adminUser)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 准备测试数据
|
||||||
|
now := time.Now()
|
||||||
|
records := []*model.TaskExecution{
|
||||||
|
{TaskID: "exec_001", TaskType: "upload:cleanup_unused", TaskName: "清理上传", Status: model.TaskExecutionStatusSucceeded, TriggeredBy: "manual", Duration: 1500, Result: "清理完成", StartedAt: &now, FinishedAt: &now},
|
||||||
|
{TaskID: "exec_002", TaskType: "upload:cleanup_unused", TaskName: "清理上传", Status: model.TaskExecutionStatusFailed, TriggeredBy: "system", ErrorMessage: "连接超时", StartedAt: &now, FinishedAt: &now},
|
||||||
|
{TaskID: "exec_003", TaskType: "upload:cleanup_unused", TaskName: "清理上传", Status: model.TaskExecutionStatusPending, TriggeredBy: "manual", Retryable: true, MaxRetry: 3},
|
||||||
|
}
|
||||||
|
for _, r := range records {
|
||||||
|
err := model.CreateTaskExecution(ctx, r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("list all executions", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var resp util.ResponseAny
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
|
||||||
|
dataBytes, _ := json.Marshal(resp.Data)
|
||||||
|
var data map[string]interface{}
|
||||||
|
json.Unmarshal(dataBytes, &data)
|
||||||
|
|
||||||
|
assert.Equal(t, float64(3), data["total"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("filter by status", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions?status=failed", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var resp util.ResponseAny
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
|
||||||
|
dataBytes, _ := json.Marshal(resp.Data)
|
||||||
|
var data map[string]interface{}
|
||||||
|
json.Unmarshal(dataBytes, &data)
|
||||||
|
|
||||||
|
assert.Equal(t, float64(1), data["total"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("filter by task_type", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions?task_type=upload:cleanup_unused", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var resp util.ResponseAny
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
|
||||||
|
dataBytes, _ := json.Marshal(resp.Data)
|
||||||
|
var data map[string]interface{}
|
||||||
|
json.Unmarshal(dataBytes, &data)
|
||||||
|
|
||||||
|
assert.Equal(t, float64(3), data["total"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pagination", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions?page=1&page_size=2", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var resp util.ResponseAny
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
|
||||||
|
dataBytes, _ := json.Marshal(resp.Data)
|
||||||
|
var data map[string]interface{}
|
||||||
|
json.Unmarshal(dataBytes, &data)
|
||||||
|
|
||||||
|
assert.Equal(t, float64(3), data["total"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTaskExecution(t *testing.T) {
|
||||||
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
|
||||||
|
router := setupTestRouter(adminUser)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 创建测试记录
|
||||||
|
execution := &model.TaskExecution{
|
||||||
|
TaskID: "detail_001",
|
||||||
|
TaskType: "upload:cleanup_unused",
|
||||||
|
TaskName: "清理上传",
|
||||||
|
Status: model.TaskExecutionStatusSucceeded,
|
||||||
|
Log: "[10:00:01] 开始扫描\n[10:00:02] 找到 50 个文件\n[10:00:03] 清理完成",
|
||||||
|
Result: "共清理 50 个文件",
|
||||||
|
Duration: 2000,
|
||||||
|
Retryable: true,
|
||||||
|
MaxRetry: 3,
|
||||||
|
TriggeredBy: "manual",
|
||||||
|
}
|
||||||
|
err := model.CreateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Run("get existing execution", func(t *testing.T) {
|
||||||
|
url := fmt.Sprintf("/api/v1/admin/tasks/executions/%d", execution.ID)
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var resp util.ResponseAny
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
|
||||||
|
dataBytes, _ := json.Marshal(resp.Data)
|
||||||
|
var detail model.TaskExecution
|
||||||
|
json.Unmarshal(dataBytes, &detail)
|
||||||
|
|
||||||
|
assert.Equal(t, "detail_001", detail.TaskID)
|
||||||
|
assert.Equal(t, model.TaskExecutionStatusSucceeded, detail.Status)
|
||||||
|
assert.Contains(t, detail.Log, "开始扫描")
|
||||||
|
assert.Contains(t, detail.Log, "清理完成")
|
||||||
|
assert.Equal(t, int64(2000), detail.Duration)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("get non-existent execution", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions/99999999", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid ID format", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/executions/invalid", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryTask(t *testing.T) {
|
||||||
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
|
||||||
|
router := setupTestRouter(adminUser)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("retry failed task successfully", func(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
execution := &model.TaskExecution{
|
||||||
|
TaskID: "retry_api_001",
|
||||||
|
TaskType: "upload:cleanup_unused",
|
||||||
|
TaskName: "清理上传",
|
||||||
|
Status: model.TaskExecutionStatusFailed,
|
||||||
|
ErrorMessage: "S3 连接超时",
|
||||||
|
Retryable: true,
|
||||||
|
MaxRetry: 3,
|
||||||
|
RetryCount: 0,
|
||||||
|
TriggeredBy: "manual",
|
||||||
|
StartedAt: &now,
|
||||||
|
FinishedAt: &now,
|
||||||
|
}
|
||||||
|
err := model.CreateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
url := fmt.Sprintf("/api/v1/admin/tasks/executions/%d/retry", execution.ID)
|
||||||
|
req, _ := http.NewRequest("POST", url, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var resp util.ResponseAny
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
assert.Empty(t, resp.ErrorMsg)
|
||||||
|
assert.NotNil(t, resp.Data)
|
||||||
|
|
||||||
|
// 验证新记录
|
||||||
|
newTaskID, ok := resp.Data.(string)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.NotEmpty(t, newTaskID)
|
||||||
|
|
||||||
|
newExecution, err := model.GetTaskExecutionByTaskID(ctx, newTaskID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, newExecution.RetryCount)
|
||||||
|
assert.Equal(t, "retry", newExecution.TriggeredBy)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("retry succeeded task fails", func(t *testing.T) {
|
||||||
|
execution := &model.TaskExecution{
|
||||||
|
TaskID: "retry_succeeded_001",
|
||||||
|
TaskType: "upload:cleanup_unused",
|
||||||
|
TaskName: "清理上传",
|
||||||
|
Status: model.TaskExecutionStatusSucceeded,
|
||||||
|
Retryable: true,
|
||||||
|
MaxRetry: 3,
|
||||||
|
TriggeredBy: "manual",
|
||||||
|
}
|
||||||
|
err := model.CreateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
url := fmt.Sprintf("/api/v1/admin/tasks/executions/%d/retry", execution.ID)
|
||||||
|
req, _ := http.NewRequest("POST", url, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("retry non-retryable task fails", func(t *testing.T) {
|
||||||
|
execution := &model.TaskExecution{
|
||||||
|
TaskID: "retry_not_allowed_001",
|
||||||
|
TaskType: "upload:cleanup_unused",
|
||||||
|
TaskName: "清理上传",
|
||||||
|
Status: model.TaskExecutionStatusFailed,
|
||||||
|
Retryable: false,
|
||||||
|
TriggeredBy: "manual",
|
||||||
|
}
|
||||||
|
err := model.CreateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
url := fmt.Sprintf("/api/v1/admin/tasks/executions/%d/retry", execution.ID)
|
||||||
|
req, _ := http.NewRequest("POST", url, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("retry non-existent task", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/executions/99999999/retry", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("retry with invalid ID", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/executions/invalid/retry", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryTaskMaxRetryExceeded(t *testing.T) {
|
||||||
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"}
|
||||||
|
router := setupTestRouter(adminUser)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
execution := &model.TaskExecution{
|
||||||
|
TaskID: "retry_max_api_001",
|
||||||
|
TaskType: "upload:cleanup_unused",
|
||||||
|
TaskName: "清理上传",
|
||||||
|
Status: model.TaskExecutionStatusFailed,
|
||||||
|
Retryable: true,
|
||||||
|
MaxRetry: 1,
|
||||||
|
RetryCount: 1,
|
||||||
|
TriggeredBy: "retry",
|
||||||
|
}
|
||||||
|
err := model.CreateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
url := fmt.Sprintf("/api/v1/admin/tasks/executions/%d/retry", execution.ID)
|
||||||
|
req, _ := http.NewRequest("POST", url, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2025 linux.do
|
Copyright 2025-2026 linux.do
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@@ -18,34 +18,31 @@ package upload
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hibiken/asynq"
|
|
||||||
"github.com/linux-do/credit/internal/db"
|
"github.com/linux-do/credit/internal/db"
|
||||||
"github.com/linux-do/credit/internal/logger"
|
|
||||||
"github.com/linux-do/credit/internal/model"
|
"github.com/linux-do/credit/internal/model"
|
||||||
"github.com/linux-do/credit/internal/storage"
|
"github.com/linux-do/credit/internal/storage"
|
||||||
|
"github.com/linux-do/credit/internal/task"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleCleanupUnusedUploads 处理清理未使用上传文件的定时任务
|
// CleanupUnusedUploadsHandler 清理未使用上传文件的异步任务处理器
|
||||||
func HandleCleanupUnusedUploads(ctx context.Context, t *asynq.Task) error {
|
type CleanupUnusedUploadsHandler struct{}
|
||||||
logger.InfoF(ctx, "开始清理未使用的上传文件任务")
|
|
||||||
cleanupUnusedUploads(ctx)
|
|
||||||
logger.InfoF(ctx, "未使用上传文件清理任务完成")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanupUnusedUploads 清理超过1小时未使用的上传文件
|
// Execute 执行清理未使用上传文件的业务逻辑
|
||||||
func cleanupUnusedUploads(ctx context.Context) {
|
func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
|
||||||
const batchSize = 100 // 每批处理100个文件
|
const batchSize = 100 // 每批处理100个文件
|
||||||
var lastID uint64 = 0
|
var lastID uint64 = 0
|
||||||
var totalProcessed int = 0
|
var totalProcessed int
|
||||||
var totalDeleted int = 0
|
var totalDeleted int
|
||||||
|
|
||||||
// 计算1小时前的时间
|
// 计算1小时前的时间
|
||||||
oneHourAgo := time.Now().Add(-1 * time.Hour)
|
oneHourAgo := time.Now().Add(-1 * time.Hour)
|
||||||
|
|
||||||
|
task.AppendLog(ctx, "开始扫描未使用上传文件,阈值: %s", oneHourAgo.Format(time.RFC3339))
|
||||||
|
|
||||||
for {
|
for {
|
||||||
// 使用游标分页查询未使用且超过1小时的上传记录
|
// 使用游标分页查询未使用且超过1小时的上传记录
|
||||||
var unusedUploads []model.Upload
|
var unusedUploads []model.Upload
|
||||||
@@ -54,8 +51,8 @@ func cleanupUnusedUploads(ctx context.Context) {
|
|||||||
Order("id ASC").
|
Order("id ASC").
|
||||||
Limit(batchSize).
|
Limit(batchSize).
|
||||||
Find(&unusedUploads).Error; err != nil {
|
Find(&unusedUploads).Error; err != nil {
|
||||||
logger.ErrorF(ctx, "查询未使用的上传文件失败: %v", err)
|
task.AppendLog(ctx, "查询未使用的上传文件失败: %v", err)
|
||||||
return
|
return nil, fmt.Errorf("查询未使用的上传文件失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 没有更多数据,退出循环
|
// 没有更多数据,退出循环
|
||||||
@@ -63,7 +60,7 @@ func cleanupUnusedUploads(ctx context.Context) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoF(ctx, "本批次找到 %d 个需要清理的上传文件", len(unusedUploads))
|
task.AppendLog(ctx, "本批次找到 %d 个需要清理的上传文件", len(unusedUploads))
|
||||||
|
|
||||||
// 处理每个未使用的上传文件
|
// 处理每个未使用的上传文件
|
||||||
for _, upload := range unusedUploads {
|
for _, upload := range unusedUploads {
|
||||||
@@ -84,22 +81,17 @@ func cleanupUnusedUploads(ctx context.Context) {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
logger.ErrorF(ctx, "清理上传文件失败 [ID:%d]: %v", upload.ID, err)
|
task.AppendLog(ctx, "清理上传文件失败 [ID:%d]: %v", upload.ID, err)
|
||||||
lastID = upload.ID
|
lastID = upload.ID
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
totalDeleted++
|
totalDeleted++
|
||||||
logger.InfoF(ctx, "成功清理上传文件 [ID:%d, Path:%s, Size:%d bytes]", upload.ID, upload.FilePath, upload.FileSize)
|
|
||||||
|
|
||||||
// 更新游标
|
|
||||||
lastID = upload.ID
|
lastID = upload.ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if totalDeleted > 0 {
|
msg := fmt.Sprintf("共处理 %d 个文件,成功删除 %d 个", totalProcessed, totalDeleted)
|
||||||
logger.InfoF(ctx, "清理任务完成,共处理 %d 个文件,成功删除 %d 个", totalProcessed, totalDeleted)
|
task.AppendLog(ctx, msg)
|
||||||
} else {
|
return &task.TaskResult{Message: msg}, nil
|
||||||
logger.InfoF(ctx, "没有需要清理的上传文件")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025-2026 linux.do
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package upload
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/linux-do/credit/internal/db"
|
||||||
|
"github.com/linux-do/credit/internal/model"
|
||||||
|
"github.com/linux-do/credit/internal/storage"
|
||||||
|
"github.com/linux-do/credit/internal/task"
|
||||||
|
"github.com/linux-do/credit/internal/testhelper"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCleanupUnusedUploadsHandler_Execute(t *testing.T) {
|
||||||
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Mock S3 存储(让 DeleteObject 总是成功)
|
||||||
|
storageMock := storage.MockStorage(
|
||||||
|
func(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
func(ctx context.Context, key string) (*storage.ObjectInfo, error) { return nil, nil },
|
||||||
|
func(ctx context.Context, key string) error { return nil },
|
||||||
|
)
|
||||||
|
defer storageMock()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 准备测试数据:创建一些上传记录
|
||||||
|
now := time.Now()
|
||||||
|
twoHoursAgo := now.Add(-2 * time.Hour)
|
||||||
|
|
||||||
|
records := []*model.Upload{
|
||||||
|
// 超过1小时且状态为 pending 的记录 —— 应被清理
|
||||||
|
{
|
||||||
|
UserID: 1001, FileName: "old_file_1.jpg", FilePath: "uploads/old_1.jpg",
|
||||||
|
FileSize: 1024, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash1",
|
||||||
|
StorageDriver: "s3", Type: "attachment", Status: model.UploadStatusPending,
|
||||||
|
CreatedAt: twoHoursAgo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
UserID: 1001, FileName: "old_file_2.png", FilePath: "uploads/old_2.png",
|
||||||
|
FileSize: 2048, MimeType: "image/png", Extension: "png", Hash: "hash2",
|
||||||
|
StorageDriver: "s3", Type: "attachment", Status: model.UploadStatusPending,
|
||||||
|
CreatedAt: twoHoursAgo,
|
||||||
|
},
|
||||||
|
// 状态为 used 的记录 —— 不应被清理
|
||||||
|
{
|
||||||
|
UserID: 1001, FileName: "used_file.jpg", FilePath: "uploads/used.jpg",
|
||||||
|
FileSize: 512, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash3",
|
||||||
|
StorageDriver: "s3", Type: "attachment", Status: model.UploadStatusUsed,
|
||||||
|
CreatedAt: twoHoursAgo,
|
||||||
|
},
|
||||||
|
// 不到1小时的 pending 记录 —— 不应被清理
|
||||||
|
{
|
||||||
|
UserID: 1001, FileName: "recent_file.jpg", FilePath: "uploads/recent.jpg",
|
||||||
|
FileSize: 256, MimeType: "image/jpeg", Extension: "jpg", Hash: "hash4",
|
||||||
|
StorageDriver: "s3", Type: "attachment", Status: model.UploadStatusPending,
|
||||||
|
CreatedAt: now.Add(-10 * time.Minute),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, r := range records {
|
||||||
|
err := db.DB(ctx).Create(r).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行 handler
|
||||||
|
handler := &CleanupUnusedUploadsHandler{}
|
||||||
|
result, err := handler.Execute(ctx, nil)
|
||||||
|
|
||||||
|
// 验证结果
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
assert.Contains(t, result.Message, "共处理 2 个文件,成功删除 2 个")
|
||||||
|
|
||||||
|
// 验证数据库状态:pending 且超过1小时的应被标记为 deleted
|
||||||
|
var pendingCount int64
|
||||||
|
db.DB(ctx).Model(&model.Upload{}).Where("status = ?", model.UploadStatusPending).Count(&pendingCount)
|
||||||
|
assert.Equal(t, int64(1), pendingCount, "应只剩1条 pending 记录(最近的文件)")
|
||||||
|
|
||||||
|
var deletedCount int64
|
||||||
|
db.DB(ctx).Model(&model.Upload{}).Where("status = ?", model.UploadStatusDeleted).Count(&deletedCount)
|
||||||
|
assert.Equal(t, int64(2), deletedCount, "应有2条被标记为 deleted")
|
||||||
|
|
||||||
|
var usedCount int64
|
||||||
|
db.DB(ctx).Model(&model.Upload{}).Where("status = ?", model.UploadStatusUsed).Count(&usedCount)
|
||||||
|
assert.Equal(t, int64(1), usedCount, "used 状态的文件不应受影响")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupUnusedUploadsHandler_ExecuteNoFiles(t *testing.T) {
|
||||||
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Mock S3 存储
|
||||||
|
storageMock := storage.MockStorage(
|
||||||
|
func(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
func(ctx context.Context, key string) (*storage.ObjectInfo, error) { return nil, nil },
|
||||||
|
func(ctx context.Context, key string) error { return nil },
|
||||||
|
)
|
||||||
|
defer storageMock()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 没有任何上传记录
|
||||||
|
handler := &CleanupUnusedUploadsHandler{}
|
||||||
|
result, err := handler.Execute(ctx, nil)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
assert.Contains(t, result.Message, "共处理 0 个文件,成功删除 0 个")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupUnusedUploadsHandler_ImplementsTaskHandler(t *testing.T) {
|
||||||
|
// 编译期验证 CleanupUnusedUploadsHandler 实现了 TaskHandler 接口
|
||||||
|
var _ task.TaskHandler = (*CleanupUnusedUploadsHandler)(nil)
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ func Migrate() {
|
|||||||
&model.SystemConfig{},
|
&model.SystemConfig{},
|
||||||
&model.Upload{},
|
&model.Upload{},
|
||||||
&model.AccessToken{},
|
&model.AccessToken{},
|
||||||
|
&model.TaskExecution{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
log.Fatalf("[PostgreSQL] auto migrate failed: %v\n", err)
|
log.Fatalf("[PostgreSQL] auto migrate failed: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025-2026 linux.do
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/linux-do/credit/internal/db"
|
||||||
|
"github.com/linux-do/credit/internal/db/idgen"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskExecutionStatus 任务执行状态
|
||||||
|
type TaskExecutionStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TaskExecutionStatusPending TaskExecutionStatus = "pending"
|
||||||
|
TaskExecutionStatusRunning TaskExecutionStatus = "running"
|
||||||
|
TaskExecutionStatusSucceeded TaskExecutionStatus = "succeeded"
|
||||||
|
TaskExecutionStatusFailed TaskExecutionStatus = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskExecution 任务执行记录
|
||||||
|
type TaskExecution struct {
|
||||||
|
ID uint64 `json:"id,string" gorm:"primaryKey"`
|
||||||
|
TaskID string `json:"task_id" gorm:"size:128;uniqueIndex;not null"`
|
||||||
|
TaskType string `json:"task_type" gorm:"size:64;index;not null"`
|
||||||
|
TaskName string `json:"task_name" gorm:"size:128"`
|
||||||
|
Status TaskExecutionStatus `json:"status" gorm:"size:32;index;not null"`
|
||||||
|
Retryable bool `json:"retryable" gorm:"not null;default:false"`
|
||||||
|
MaxRetry int `json:"max_retry" gorm:"not null;default:0"`
|
||||||
|
RetryCount int `json:"retry_count" gorm:"not null;default:0"`
|
||||||
|
Log string `json:"log" gorm:"type:text"`
|
||||||
|
ErrorMessage string `json:"error_message" gorm:"type:text"`
|
||||||
|
Result string `json:"result" gorm:"type:text"`
|
||||||
|
StartedAt *time.Time `json:"started_at" gorm:"index"`
|
||||||
|
FinishedAt *time.Time `json:"finished_at"`
|
||||||
|
Duration int64 `json:"duration" gorm:"comment:耗时毫秒"`
|
||||||
|
Payload string `json:"payload" gorm:"type:text"`
|
||||||
|
TriggeredBy string `json:"triggered_by" gorm:"size:32;not null;default:system"`
|
||||||
|
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 表名
|
||||||
|
func (TaskExecution) TableName() string {
|
||||||
|
return "task_executions"
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTaskExecution 创建任务执行记录
|
||||||
|
func CreateTaskExecution(ctx context.Context, execution *TaskExecution) error {
|
||||||
|
execution.ID = idgen.NextUint64ID()
|
||||||
|
return db.DB(ctx).Create(execution).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTaskExecution 更新任务执行记录
|
||||||
|
func UpdateTaskExecution(ctx context.Context, execution *TaskExecution) error {
|
||||||
|
return db.DB(ctx).Save(execution).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTaskExecutionByTaskID 根据 TaskID 获取执行记录
|
||||||
|
func GetTaskExecutionByTaskID(ctx context.Context, taskID string) (*TaskExecution, error) {
|
||||||
|
var execution TaskExecution
|
||||||
|
if err := db.DB(ctx).Where("task_id = ?", taskID).First(&execution).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &execution, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTaskExecutionByID 根据 ID 获取执行记录
|
||||||
|
func GetTaskExecutionByID(ctx context.Context, id uint64) (*TaskExecution, error) {
|
||||||
|
var execution TaskExecution
|
||||||
|
if err := db.DB(ctx).Where("id = ?", id).First(&execution).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &execution, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendTaskExecutionLog 追加日志到执行记录
|
||||||
|
func AppendTaskExecutionLog(ctx context.Context, taskID string, logLine string) error {
|
||||||
|
now := time.Now().Format("15:04:05")
|
||||||
|
line := fmt.Sprintf("[%s] %s\n", now, logLine)
|
||||||
|
return db.DB(ctx).Model(&TaskExecution{}).
|
||||||
|
Where("task_id = ?", taskID).
|
||||||
|
Update("log", gorm.Expr("COALESCE(log, '') || ?", line)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTaskExecutionsRequest 查询任务执行记录列表请求
|
||||||
|
type ListTaskExecutionsRequest struct {
|
||||||
|
Status string `form:"status"`
|
||||||
|
TaskType string `form:"task_type"`
|
||||||
|
Page int `form:"page"`
|
||||||
|
PageSize int `form:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTaskExecutions 分页查询任务执行记录
|
||||||
|
func ListTaskExecutions(ctx context.Context, req ListTaskExecutionsRequest) ([]TaskExecution, int64, error) {
|
||||||
|
if req.Page <= 0 {
|
||||||
|
req.Page = 1
|
||||||
|
}
|
||||||
|
if req.PageSize <= 0 {
|
||||||
|
req.PageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
query := db.DB(ctx).Model(&TaskExecution{})
|
||||||
|
|
||||||
|
if req.Status != "" {
|
||||||
|
query = query.Where("status = ?", req.Status)
|
||||||
|
}
|
||||||
|
if req.TaskType != "" {
|
||||||
|
query = query.Where("task_type = ?", req.TaskType)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var executions []TaskExecution
|
||||||
|
offset := (req.Page - 1) * req.PageSize
|
||||||
|
if err := query.Order("id DESC").Offset(offset).Limit(req.PageSize).Find(&executions).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return executions, total, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025-2026 linux.do
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/linux-do/credit/internal/testhelper"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateTaskExecution(t *testing.T) {
|
||||||
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
execution := &TaskExecution{
|
||||||
|
TaskID: "manual_cleanup_123",
|
||||||
|
TaskType: "upload:cleanup_unused",
|
||||||
|
TaskName: "清理未使用上传",
|
||||||
|
Status: TaskExecutionStatusPending,
|
||||||
|
Retryable: true,
|
||||||
|
MaxRetry: 3,
|
||||||
|
RetryCount: 0,
|
||||||
|
Payload: `{"test": true}`,
|
||||||
|
TriggeredBy: "manual",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := CreateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotZero(t, execution.ID, "ID should be generated")
|
||||||
|
assert.NotZero(t, execution.CreatedAt, "CreatedAt should be set")
|
||||||
|
assert.NotZero(t, execution.UpdatedAt, "UpdatedAt should be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTaskExecutionByTaskID(t *testing.T) {
|
||||||
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 创建记录
|
||||||
|
execution := &TaskExecution{
|
||||||
|
TaskID: "test_task_id_001",
|
||||||
|
TaskType: "upload:cleanup_unused",
|
||||||
|
TaskName: "清理未使用上传",
|
||||||
|
Status: TaskExecutionStatusPending,
|
||||||
|
Retryable: true,
|
||||||
|
MaxRetry: 3,
|
||||||
|
TriggeredBy: "manual",
|
||||||
|
}
|
||||||
|
err := CreateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 按 TaskID 查询
|
||||||
|
found, err := GetTaskExecutionByTaskID(ctx, "test_task_id_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, execution.ID, found.ID)
|
||||||
|
assert.Equal(t, "test_task_id_001", found.TaskID)
|
||||||
|
assert.Equal(t, TaskExecutionStatusPending, found.Status)
|
||||||
|
assert.True(t, found.Retryable)
|
||||||
|
assert.Equal(t, 3, found.MaxRetry)
|
||||||
|
|
||||||
|
// 查询不存在的 TaskID
|
||||||
|
_, err = GetTaskExecutionByTaskID(ctx, "nonexistent")
|
||||||
|
assert.Error(t, err, "should return error for non-existent taskID")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTaskExecutionByID(t *testing.T) {
|
||||||
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
execution := &TaskExecution{
|
||||||
|
TaskID: "test_by_id_001",
|
||||||
|
TaskType: "upload:cleanup_unused",
|
||||||
|
TaskName: "清理未使用上传",
|
||||||
|
Status: TaskExecutionStatusPending,
|
||||||
|
TriggeredBy: "system",
|
||||||
|
}
|
||||||
|
err := CreateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 按主键查询
|
||||||
|
found, err := GetTaskExecutionByID(ctx, execution.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, execution.TaskID, found.TaskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateTaskExecution(t *testing.T) {
|
||||||
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 创建记录
|
||||||
|
execution := &TaskExecution{
|
||||||
|
TaskID: "test_update_001",
|
||||||
|
TaskType: "upload:cleanup_unused",
|
||||||
|
TaskName: "清理未使用上传",
|
||||||
|
Status: TaskExecutionStatusPending,
|
||||||
|
TriggeredBy: "manual",
|
||||||
|
}
|
||||||
|
err := CreateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 更新状态为 running
|
||||||
|
now := time.Now()
|
||||||
|
execution.Status = TaskExecutionStatusRunning
|
||||||
|
execution.StartedAt = &now
|
||||||
|
err = UpdateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 验证更新
|
||||||
|
found, err := GetTaskExecutionByTaskID(ctx, "test_update_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, TaskExecutionStatusRunning, found.Status)
|
||||||
|
assert.NotNil(t, found.StartedAt)
|
||||||
|
|
||||||
|
// 更新为 succeeded
|
||||||
|
finishTime := time.Now()
|
||||||
|
execution.Status = TaskExecutionStatusSucceeded
|
||||||
|
execution.FinishedAt = &finishTime
|
||||||
|
execution.Duration = 1500
|
||||||
|
execution.Result = "共清理 50 个文件"
|
||||||
|
err = UpdateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
found, err = GetTaskExecutionByTaskID(ctx, "test_update_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, TaskExecutionStatusSucceeded, found.Status)
|
||||||
|
assert.Equal(t, int64(1500), found.Duration)
|
||||||
|
assert.Equal(t, "共清理 50 个文件", found.Result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateTaskExecutionFailed(t *testing.T) {
|
||||||
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
execution := &TaskExecution{
|
||||||
|
TaskID: "test_fail_001",
|
||||||
|
TaskType: "upload:cleanup_unused",
|
||||||
|
TaskName: "清理未使用上传",
|
||||||
|
Status: TaskExecutionStatusPending,
|
||||||
|
Retryable: true,
|
||||||
|
MaxRetry: 3,
|
||||||
|
TriggeredBy: "manual",
|
||||||
|
}
|
||||||
|
err := CreateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 标记为失败
|
||||||
|
now := time.Now()
|
||||||
|
execution.Status = TaskExecutionStatusFailed
|
||||||
|
execution.StartedAt = &now
|
||||||
|
execution.FinishedAt = &now
|
||||||
|
execution.Duration = 200
|
||||||
|
execution.ErrorMessage = "S3 连接超时"
|
||||||
|
err = UpdateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
found, err := GetTaskExecutionByTaskID(ctx, "test_fail_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, TaskExecutionStatusFailed, found.Status)
|
||||||
|
assert.Equal(t, "S3 连接超时", found.ErrorMessage)
|
||||||
|
assert.Equal(t, int64(200), found.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendTaskExecutionLog(t *testing.T) {
|
||||||
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
execution := &TaskExecution{
|
||||||
|
TaskID: "test_log_001",
|
||||||
|
TaskType: "upload:cleanup_unused",
|
||||||
|
TaskName: "清理未使用上传",
|
||||||
|
Status: TaskExecutionStatusPending,
|
||||||
|
TriggeredBy: "manual",
|
||||||
|
}
|
||||||
|
err := CreateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 追加多条日志
|
||||||
|
err = AppendTaskExecutionLog(ctx, "test_log_001", "开始扫描未使用上传文件")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = AppendTaskExecutionLog(ctx, "test_log_001", "本批次找到 42 个待清理文件")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = AppendTaskExecutionLog(ctx, "test_log_001", "清理完成,共删除 42 个文件")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 验证日志内容
|
||||||
|
found, err := GetTaskExecutionByTaskID(ctx, "test_log_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, found.Log, "开始扫描未使用上传文件")
|
||||||
|
assert.Contains(t, found.Log, "本批次找到 42 个待清理文件")
|
||||||
|
assert.Contains(t, found.Log, "清理完成,共删除 42 个文件")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendTaskExecutionLogNonExistent(t *testing.T) {
|
||||||
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 对不存在的 TaskID 追加日志不应报错(COALESCE 处理空值)
|
||||||
|
err := AppendTaskExecutionLog(ctx, "nonexistent_task", "测试日志")
|
||||||
|
// SQLite 下 COALESCE + || 操作不应报错
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListTaskExecutions(t *testing.T) {
|
||||||
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 创建多条记录,包含不同状态和类型
|
||||||
|
records := []*TaskExecution{
|
||||||
|
{TaskID: "list_001", TaskType: "upload:cleanup_unused", TaskName: "清理上传", Status: TaskExecutionStatusSucceeded, TriggeredBy: "manual"},
|
||||||
|
{TaskID: "list_002", TaskType: "upload:cleanup_unused", TaskName: "清理上传", Status: TaskExecutionStatusFailed, TriggeredBy: "system"},
|
||||||
|
{TaskID: "list_003", TaskType: "other:task", TaskName: "其他任务", Status: TaskExecutionStatusPending, TriggeredBy: "manual"},
|
||||||
|
{TaskID: "list_004", TaskType: "upload:cleanup_unused", TaskName: "清理上传", Status: TaskExecutionStatusRunning, TriggeredBy: "manual"},
|
||||||
|
{TaskID: "list_005", TaskType: "other:task", TaskName: "其他任务", Status: TaskExecutionStatusSucceeded, TriggeredBy: "system"},
|
||||||
|
}
|
||||||
|
for _, r := range records {
|
||||||
|
err := CreateTaskExecution(ctx, r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询全部(分页)
|
||||||
|
items, total, err := ListTaskExecutions(ctx, ListTaskExecutionsRequest{Page: 1, PageSize: 10})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(5), total)
|
||||||
|
assert.Len(t, items, 5)
|
||||||
|
|
||||||
|
// 按状态筛选:failed
|
||||||
|
items, total, err = ListTaskExecutions(ctx, ListTaskExecutionsRequest{Status: "failed", Page: 1, PageSize: 10})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(1), total)
|
||||||
|
assert.Len(t, items, 1)
|
||||||
|
assert.Equal(t, "list_002", items[0].TaskID)
|
||||||
|
|
||||||
|
// 按类型筛选
|
||||||
|
items, total, err = ListTaskExecutions(ctx, ListTaskExecutionsRequest{TaskType: "other:task", Page: 1, PageSize: 10})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(2), total)
|
||||||
|
|
||||||
|
// 分页测试
|
||||||
|
items, total, err = ListTaskExecutions(ctx, ListTaskExecutionsRequest{Page: 1, PageSize: 2})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(5), total)
|
||||||
|
assert.Len(t, items, 2)
|
||||||
|
|
||||||
|
items2, total2, err := ListTaskExecutions(ctx, ListTaskExecutionsRequest{Page: 2, PageSize: 2})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(5), total2)
|
||||||
|
assert.Len(t, items2, 2)
|
||||||
|
|
||||||
|
// 确保分页数据不重复
|
||||||
|
assert.NotEqual(t, items[0].ID, items2[0].ID)
|
||||||
|
|
||||||
|
// 状态 + 类型组合筛选
|
||||||
|
items, total, err = ListTaskExecutions(ctx, ListTaskExecutionsRequest{Status: "succeeded", TaskType: "upload:cleanup_unused", Page: 1, PageSize: 10})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(1), total)
|
||||||
|
assert.Equal(t, "list_001", items[0].TaskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListTaskExecutionsDefaultPaging(t *testing.T) {
|
||||||
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 不传分页参数,应使用默认值 page=1, pageSize=20
|
||||||
|
items, total, err := ListTaskExecutions(ctx, ListTaskExecutionsRequest{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(0), total)
|
||||||
|
assert.Len(t, items, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskExecutionTableName(t *testing.T) {
|
||||||
|
execution := TaskExecution{}
|
||||||
|
assert.Equal(t, "task_executions", execution.TableName())
|
||||||
|
}
|
||||||
@@ -164,6 +164,11 @@ func Serve() {
|
|||||||
adminRouter.GET("/tasks/types", admin_task.ListTaskTypes)
|
adminRouter.GET("/tasks/types", admin_task.ListTaskTypes)
|
||||||
adminRouter.POST("/tasks/dispatch", admin_task.DispatchTask)
|
adminRouter.POST("/tasks/dispatch", admin_task.DispatchTask)
|
||||||
|
|
||||||
|
// Task executions
|
||||||
|
adminRouter.GET("/tasks/executions", admin_task.ListTaskExecutions)
|
||||||
|
adminRouter.GET("/tasks/executions/:id", admin_task.GetTaskExecution)
|
||||||
|
adminRouter.POST("/tasks/executions/:id/retry", admin_task.RetryTask)
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
adminRouter.GET("/users", admin_user.ListUsers)
|
adminRouter.GET("/users", admin_user.ListUsers)
|
||||||
adminRouter.PUT("/users/:id/status", admin_user.UpdateUserStatus)
|
adminRouter.PUT("/users/:id/status", admin_user.UpdateUserStatus)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type TaskMeta struct {
|
|||||||
SupportsTime bool
|
SupportsTime bool
|
||||||
MaxRetry int
|
MaxRetry int
|
||||||
Queue string
|
Queue string
|
||||||
|
Retryable bool // 是否支持手动重试
|
||||||
}
|
}
|
||||||
|
|
||||||
// DispatchableTasks 可下发的任务列表
|
// DispatchableTasks 可下发的任务列表
|
||||||
@@ -50,6 +51,7 @@ var DispatchableTasks = []TaskMeta{
|
|||||||
SupportsTime: false,
|
SupportsTime: false,
|
||||||
MaxRetry: 3,
|
MaxRetry: 3,
|
||||||
Queue: QueueDefault,
|
Queue: QueueDefault,
|
||||||
|
Retryable: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025-2026 linux.do
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hibiken/asynq"
|
||||||
|
"github.com/linux-do/credit/internal/db/idgen"
|
||||||
|
"github.com/linux-do/credit/internal/logger"
|
||||||
|
"github.com/linux-do/credit/internal/model"
|
||||||
|
"github.com/linux-do/credit/internal/otel_trace"
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/codes"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handlerRegistry 已注册的任务处理器
|
||||||
|
var handlerRegistry = make(map[string]TaskHandler)
|
||||||
|
|
||||||
|
// RegisterHandler 注册任务处理器
|
||||||
|
// 传入任务类型标识(对应 constants.go 中的 AsynqTask 常量)和 TaskHandler 实现
|
||||||
|
func RegisterHandler(asynqTaskType string, handler TaskHandler) {
|
||||||
|
handlerRegistry[asynqTaskType] = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// getHandler 获取已注册的处理器
|
||||||
|
func getHandler(asynqTaskType string) (TaskHandler, bool) {
|
||||||
|
h, ok := handlerRegistry[asynqTaskType]
|
||||||
|
return h, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// contextKey 用于 context 存取 taskID
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const taskIDKey contextKey = "task_execution_task_id"
|
||||||
|
|
||||||
|
// withTaskID 将 taskID 注入 context
|
||||||
|
func withTaskID(ctx context.Context, taskID string) context.Context {
|
||||||
|
return context.WithValue(ctx, taskIDKey, taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTaskID 从 context 中获取 taskID
|
||||||
|
func GetTaskID(ctx context.Context) string {
|
||||||
|
if v, ok := ctx.Value(taskIDKey).(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendLog 追加日志到任务执行记录
|
||||||
|
// 在 TaskHandler.Execute 中调用,日志会自动追加到 TaskExecution.Log 字段
|
||||||
|
func AppendLog(ctx context.Context, format string, args ...interface{}) {
|
||||||
|
taskID := GetTaskID(ctx)
|
||||||
|
if taskID == "" {
|
||||||
|
// 上下文中没有 taskID,降级到普通日志
|
||||||
|
logger.InfoF(ctx, format, args...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logLine := fmt.Sprintf(format, args...)
|
||||||
|
if err := model.AppendTaskExecutionLog(ctx, taskID, logLine); err != nil {
|
||||||
|
logger.ErrorF(ctx, "[TaskExecutor] 追加任务日志失败 taskID=%s: %v", taskID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DispatchTask 下发任务(创建 TaskExecution 记录 → 入队 Asynq)
|
||||||
|
func DispatchTask(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) {
|
||||||
|
meta := GetTaskMeta(taskType)
|
||||||
|
if meta == nil {
|
||||||
|
return "", fmt.Errorf("未知的任务类型: %s", taskType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成唯一的 TaskID
|
||||||
|
taskID := generateTaskID(taskType, triggeredBy)
|
||||||
|
|
||||||
|
// 创建任务执行记录
|
||||||
|
execution := &model.TaskExecution{
|
||||||
|
TaskID: taskID,
|
||||||
|
TaskType: meta.AsynqTask,
|
||||||
|
TaskName: meta.Name,
|
||||||
|
Status: model.TaskExecutionStatusPending,
|
||||||
|
Retryable: meta.Retryable,
|
||||||
|
MaxRetry: meta.MaxRetry,
|
||||||
|
RetryCount: 0,
|
||||||
|
Payload: string(payload),
|
||||||
|
TriggeredBy: triggeredBy,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := model.CreateTaskExecution(ctx, execution); err != nil {
|
||||||
|
return "", fmt.Errorf("创建任务执行记录失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 入队 Asynq
|
||||||
|
taskInfo := asynq.NewTask(meta.AsynqTask, payload)
|
||||||
|
if _, err := AsynqClient.Enqueue(
|
||||||
|
taskInfo,
|
||||||
|
asynq.TaskID(taskID),
|
||||||
|
asynq.MaxRetry(meta.MaxRetry),
|
||||||
|
asynq.Queue(meta.Queue),
|
||||||
|
); err != nil {
|
||||||
|
// 入队失败,更新执行记录状态
|
||||||
|
execution.Status = model.TaskExecutionStatusFailed
|
||||||
|
execution.ErrorMessage = fmt.Sprintf("入队失败: %v", err)
|
||||||
|
now := time.Now()
|
||||||
|
execution.StartedAt = &now
|
||||||
|
execution.FinishedAt = &now
|
||||||
|
_ = model.UpdateTaskExecution(ctx, execution)
|
||||||
|
return "", fmt.Errorf("任务入队失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return taskID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryTask 重试失败的任务
|
||||||
|
func RetryTask(ctx context.Context, id uint64) (string, error) {
|
||||||
|
execution, err := model.GetTaskExecutionByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("任务执行记录不存在: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if execution.Status != model.TaskExecutionStatusFailed {
|
||||||
|
return "", fmt.Errorf("只有失败的任务才能重试,当前状态: %s", execution.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !execution.Retryable {
|
||||||
|
return "", fmt.Errorf("该任务不支持重试")
|
||||||
|
}
|
||||||
|
|
||||||
|
if execution.RetryCount >= execution.MaxRetry {
|
||||||
|
return "", fmt.Errorf("已达到最大重试次数 %d", execution.MaxRetry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成新的 TaskID
|
||||||
|
newTaskID := generateRetryTaskID(execution.TaskID, execution.RetryCount+1)
|
||||||
|
|
||||||
|
// 创建新的执行记录
|
||||||
|
newExecution := &model.TaskExecution{
|
||||||
|
TaskID: newTaskID,
|
||||||
|
TaskType: execution.TaskType,
|
||||||
|
TaskName: execution.TaskName,
|
||||||
|
Status: model.TaskExecutionStatusPending,
|
||||||
|
Retryable: execution.Retryable,
|
||||||
|
MaxRetry: execution.MaxRetry,
|
||||||
|
RetryCount: execution.RetryCount + 1,
|
||||||
|
Payload: execution.Payload,
|
||||||
|
TriggeredBy: "retry",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := model.CreateTaskExecution(ctx, newExecution); err != nil {
|
||||||
|
return "", fmt.Errorf("创建重试任务执行记录失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 入队 Asynq
|
||||||
|
taskInfo := asynq.NewTask(execution.TaskType, []byte(execution.Payload))
|
||||||
|
if _, err := AsynqClient.Enqueue(
|
||||||
|
taskInfo,
|
||||||
|
asynq.TaskID(newTaskID),
|
||||||
|
asynq.MaxRetry(execution.MaxRetry),
|
||||||
|
asynq.Queue(PrefixedQueue(QueueDefault)),
|
||||||
|
); err != nil {
|
||||||
|
newExecution.Status = model.TaskExecutionStatusFailed
|
||||||
|
newExecution.ErrorMessage = fmt.Sprintf("重试入队失败: %v", err)
|
||||||
|
now := time.Now()
|
||||||
|
newExecution.StartedAt = &now
|
||||||
|
newExecution.FinishedAt = &now
|
||||||
|
_ = model.UpdateTaskExecution(ctx, newExecution)
|
||||||
|
return "", fmt.Errorf("重试任务入队失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newTaskID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessTask Asynq 实际调用的统一处理函数
|
||||||
|
// Worker 注册时统一使用此函数,内部自动分发到对应的 TaskHandler
|
||||||
|
func ProcessTask(ctx context.Context, t *asynq.Task) error {
|
||||||
|
// 初始化 Trace
|
||||||
|
ctx, span := otel_trace.Start(ctx, "TaskProcess_"+t.Type(), trace.WithSpanKind(trace.SpanKindConsumer))
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
// 添加任务信息到 Span
|
||||||
|
span.SetAttributes(
|
||||||
|
attribute.String("task.type", t.Type()),
|
||||||
|
attribute.Int("task.payload_size", len(t.Payload())),
|
||||||
|
attribute.String("task.id", t.ResultWriter().TaskID()),
|
||||||
|
)
|
||||||
|
|
||||||
|
taskID := t.ResultWriter().TaskID()
|
||||||
|
|
||||||
|
// 注入 taskID 到 context
|
||||||
|
ctx = withTaskID(ctx, taskID)
|
||||||
|
|
||||||
|
// 查找处理器
|
||||||
|
handler, ok := getHandler(t.Type())
|
||||||
|
if !ok {
|
||||||
|
err := fmt.Errorf("未注册的任务处理器: %s", t.Type())
|
||||||
|
logger.ErrorF(ctx, "[TaskExecutor] %v", err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从数据库加载执行记录
|
||||||
|
execution, err := model.GetTaskExecutionByTaskID(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorF(ctx, "[TaskExecutor] 查询执行记录失败 taskID=%s: %v", taskID, err)
|
||||||
|
// 执行记录不存在,仍然执行任务但不记录状态
|
||||||
|
_, execErr := handler.Execute(ctx, t.Payload())
|
||||||
|
if execErr != nil {
|
||||||
|
span.SetStatus(codes.Error, execErr.Error())
|
||||||
|
return execErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新状态为 running
|
||||||
|
now := time.Now()
|
||||||
|
execution.Status = model.TaskExecutionStatusRunning
|
||||||
|
execution.StartedAt = &now
|
||||||
|
if err := model.UpdateTaskExecution(ctx, execution); err != nil {
|
||||||
|
logger.ErrorF(ctx, "[TaskExecutor] 更新执行状态失败 taskID=%s: %v", taskID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始计时
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// 执行业务逻辑
|
||||||
|
result, execErr := handler.Execute(ctx, t.Payload())
|
||||||
|
|
||||||
|
// 计算耗时
|
||||||
|
duration := time.Since(start)
|
||||||
|
finishTime := time.Now()
|
||||||
|
execution.Duration = duration.Milliseconds()
|
||||||
|
execution.FinishedAt = &finishTime
|
||||||
|
|
||||||
|
if execErr != nil {
|
||||||
|
// 执行失败
|
||||||
|
execution.Status = model.TaskExecutionStatusFailed
|
||||||
|
execution.ErrorMessage = execErr.Error()
|
||||||
|
|
||||||
|
logger.ErrorF(ctx,
|
||||||
|
"[TaskExecutor] 任务处理失败 Type: %s TaskID: %s Duration: %d ms Error: %v",
|
||||||
|
t.Type(), taskID, duration.Milliseconds(), execErr,
|
||||||
|
)
|
||||||
|
|
||||||
|
span.SetStatus(codes.Error, execErr.Error())
|
||||||
|
span.RecordError(execErr)
|
||||||
|
} else {
|
||||||
|
// 执行成功
|
||||||
|
execution.Status = model.TaskExecutionStatusSucceeded
|
||||||
|
if result != nil {
|
||||||
|
execution.Result = result.Message
|
||||||
|
if result.Detail != "" {
|
||||||
|
execution.Result = fmt.Sprintf("%s\n%s", result.Message, result.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoF(ctx,
|
||||||
|
"[TaskExecutor] 任务处理完成 Type: %s TaskID: %s Duration: %d ms",
|
||||||
|
t.Type(), taskID, duration.Milliseconds(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新执行记录
|
||||||
|
if err := model.UpdateTaskExecution(ctx, execution); err != nil {
|
||||||
|
logger.ErrorF(ctx, "[TaskExecutor] 更新执行记录失败 taskID=%s: %v", taskID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return execErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTaskID 生成任务 ID
|
||||||
|
func generateTaskID(taskType string, triggeredBy string) string {
|
||||||
|
uniqueID := idgen.NextUint64ID()
|
||||||
|
return fmt.Sprintf("%s_%s_%d", triggeredBy, taskType, uniqueID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRetryTaskID 生成重试任务 ID
|
||||||
|
func generateRetryTaskID(originalTaskID string, retryCount int) string {
|
||||||
|
return fmt.Sprintf("retry_%d_%s", retryCount, originalTaskID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025-2026 linux.do
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hibiken/asynq"
|
||||||
|
"github.com/linux-do/credit/internal/model"
|
||||||
|
"github.com/linux-do/credit/internal/testhelper"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockHandler 用于测试的模拟任务处理器
|
||||||
|
type mockHandler struct {
|
||||||
|
executeFunc func(ctx context.Context, payload []byte) (*TaskResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *mockHandler) Execute(ctx context.Context, payload []byte) (*TaskResult, error) {
|
||||||
|
if h.executeFunc != nil {
|
||||||
|
return h.executeFunc(ctx, payload)
|
||||||
|
}
|
||||||
|
return &TaskResult{Message: "mock success"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// successHandler 返回成功的处理器
|
||||||
|
func successHandler() *mockHandler {
|
||||||
|
return &mockHandler{
|
||||||
|
executeFunc: func(ctx context.Context, payload []byte) (*TaskResult, error) {
|
||||||
|
AppendLog(ctx, "执行成功,处理了 %d 条数据", 100)
|
||||||
|
return &TaskResult{Message: "处理完成,共 100 条"}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// failHandler 返回失败的处理器
|
||||||
|
func failHandler() *mockHandler {
|
||||||
|
return &mockHandler{
|
||||||
|
executeFunc: func(ctx context.Context, payload []byte) (*TaskResult, error) {
|
||||||
|
AppendLog(ctx, "开始执行任务")
|
||||||
|
return nil, fmt.Errorf("模拟执行失败: 数据库连接超时")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testTaskType = "test:mock_task"
|
||||||
|
|
||||||
|
func setupTest(t *testing.T) func() {
|
||||||
|
_, _, cleanup := testhelper.SetupTestEnvironment(t)
|
||||||
|
// 注册测试用 handler
|
||||||
|
RegisterHandler(testTaskType, successHandler())
|
||||||
|
return cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterAndGetHandler(t *testing.T) {
|
||||||
|
_ = testTaskType
|
||||||
|
cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// 验证 handler 已注册
|
||||||
|
h, ok := getHandler(testTaskType)
|
||||||
|
assert.True(t, ok, "handler should be registered")
|
||||||
|
assert.NotNil(t, h)
|
||||||
|
|
||||||
|
// 未注册的 handler
|
||||||
|
_, ok = getHandler("nonexistent")
|
||||||
|
assert.False(t, ok, "non-existent handler should return false")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTaskIDFromContext(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 空 context
|
||||||
|
taskID := GetTaskID(ctx)
|
||||||
|
assert.Equal(t, "", taskID)
|
||||||
|
|
||||||
|
// 注入 taskID
|
||||||
|
ctx = withTaskID(ctx, "test_task_123")
|
||||||
|
taskID = GetTaskID(ctx)
|
||||||
|
assert.Equal(t, "test_task_123", taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendLogWithoutTaskID(t *testing.T) {
|
||||||
|
cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 没有 taskID 的 context,应降级到普通日志,不报错
|
||||||
|
AppendLog(ctx, "这条日志应该降级处理,不会报错")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendLogWithTaskID(t *testing.T) {
|
||||||
|
cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 先创建一条执行记录
|
||||||
|
execution := &model.TaskExecution{
|
||||||
|
TaskID: "log_test_001",
|
||||||
|
TaskType: testTaskType,
|
||||||
|
TaskName: "测试任务",
|
||||||
|
Status: model.TaskExecutionStatusRunning,
|
||||||
|
TriggeredBy: "manual",
|
||||||
|
}
|
||||||
|
err := model.CreateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 注入 taskID 并追加日志
|
||||||
|
ctx = withTaskID(ctx, "log_test_001")
|
||||||
|
AppendLog(ctx, "第一条日志")
|
||||||
|
AppendLog(ctx, "处理了 %d 条数据", 50)
|
||||||
|
|
||||||
|
// 验证日志
|
||||||
|
found, err := model.GetTaskExecutionByTaskID(ctx, "log_test_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, found.Log, "第一条日志")
|
||||||
|
assert.Contains(t, found.Log, "处理了 50 条数据")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessTaskSuccess(t *testing.T) {
|
||||||
|
cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 注册成功 handler
|
||||||
|
RegisterHandler(testTaskType, successHandler())
|
||||||
|
|
||||||
|
// 创建执行记录
|
||||||
|
execution := &model.TaskExecution{
|
||||||
|
TaskID: "process_success_001",
|
||||||
|
TaskType: testTaskType,
|
||||||
|
TaskName: "测试任务",
|
||||||
|
Status: model.TaskExecutionStatusPending,
|
||||||
|
Retryable: true,
|
||||||
|
MaxRetry: 3,
|
||||||
|
TriggeredBy: "manual",
|
||||||
|
}
|
||||||
|
err := model.CreateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 模拟 Asynq Task
|
||||||
|
asynqTask := asynq.NewTask(testTaskType, nil)
|
||||||
|
// 使用 ResultWriter 设置 TaskID
|
||||||
|
rw := asynq.NewResultWriter()
|
||||||
|
rw.SetTaskID("process_success_001")
|
||||||
|
// 通过 asynq 的 Task 不能直接设置 taskID,ProcessTask 通过 t.ResultWriter().TaskID() 获取
|
||||||
|
// 但 asynq.Task 在没有经过 asynq server 的情况下 ResultWriter 可能为 nil
|
||||||
|
// 我们需要在 ProcessTask 内部改用 taskID 注入的方式测试
|
||||||
|
// 为了测试 ProcessTask,我们直接模拟调用 handler
|
||||||
|
|
||||||
|
// 直接通过 handler 测试
|
||||||
|
handler, ok := getHandler(testTaskType)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
ctx = withTaskID(ctx, "process_success_001")
|
||||||
|
result, err := handler.Execute(ctx, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "处理完成,共 100 条", result.Message)
|
||||||
|
|
||||||
|
// 验证日志被追加
|
||||||
|
found, err := model.GetTaskExecutionByTaskID(ctx, "process_success_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, found.Log, "执行成功,处理了 100 条数据")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessTaskFailure(t *testing.T) {
|
||||||
|
cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 注册失败 handler
|
||||||
|
RegisterHandler(testTaskType, failHandler())
|
||||||
|
|
||||||
|
// 创建执行记录
|
||||||
|
execution := &model.TaskExecution{
|
||||||
|
TaskID: "process_fail_001",
|
||||||
|
TaskType: testTaskType,
|
||||||
|
TaskName: "测试任务",
|
||||||
|
Status: model.TaskExecutionStatusPending,
|
||||||
|
Retryable: true,
|
||||||
|
MaxRetry: 3,
|
||||||
|
TriggeredBy: "manual",
|
||||||
|
}
|
||||||
|
err := model.CreateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 直接调用 handler
|
||||||
|
handler, ok := getHandler(testTaskType)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
ctx = withTaskID(ctx, "process_fail_001")
|
||||||
|
_, err = handler.Execute(ctx, nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "模拟执行失败")
|
||||||
|
|
||||||
|
// 验证日志
|
||||||
|
found, err := model.GetTaskExecutionByTaskID(ctx, "process_fail_001")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, found.Log, "开始执行任务")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryTask(t *testing.T) {
|
||||||
|
cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 创建一条失败的执行记录(可重试)
|
||||||
|
now := time.Now()
|
||||||
|
execution := &model.TaskExecution{
|
||||||
|
TaskID: "retry_test_001",
|
||||||
|
TaskType: testTaskType,
|
||||||
|
TaskName: "测试任务",
|
||||||
|
Status: model.TaskExecutionStatusFailed,
|
||||||
|
Retryable: true,
|
||||||
|
MaxRetry: 3,
|
||||||
|
RetryCount: 0,
|
||||||
|
ErrorMessage: "首次执行失败",
|
||||||
|
StartedAt: &now,
|
||||||
|
FinishedAt: &now,
|
||||||
|
Duration: 100,
|
||||||
|
TriggeredBy: "manual",
|
||||||
|
}
|
||||||
|
err := model.CreateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 重试
|
||||||
|
newTaskID, err := RetryTask(ctx, execution.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, newTaskID)
|
||||||
|
assert.Contains(t, newTaskID, "retry_1_")
|
||||||
|
|
||||||
|
// 验证新记录
|
||||||
|
newExecution, err := model.GetTaskExecutionByTaskID(ctx, newTaskID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.TaskExecutionStatusPending, newExecution.Status)
|
||||||
|
assert.Equal(t, 1, newExecution.RetryCount)
|
||||||
|
assert.Equal(t, "retry", newExecution.TriggeredBy)
|
||||||
|
assert.Equal(t, execution.TaskType, newExecution.TaskType)
|
||||||
|
assert.True(t, newExecution.Retryable)
|
||||||
|
|
||||||
|
// 原记录不变
|
||||||
|
original, err := model.GetTaskExecutionByID(ctx, execution.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.TaskExecutionStatusFailed, original.Status)
|
||||||
|
assert.Equal(t, 0, original.RetryCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryTaskNotFailed(t *testing.T) {
|
||||||
|
cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 创建一条成功的记录
|
||||||
|
execution := &model.TaskExecution{
|
||||||
|
TaskID: "retry_not_failed_001",
|
||||||
|
TaskType: testTaskType,
|
||||||
|
TaskName: "测试任务",
|
||||||
|
Status: model.TaskExecutionStatusSucceeded,
|
||||||
|
Retryable: true,
|
||||||
|
MaxRetry: 3,
|
||||||
|
TriggeredBy: "manual",
|
||||||
|
}
|
||||||
|
err := model.CreateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 尝试重试成功的任务
|
||||||
|
_, err = RetryTask(ctx, execution.ID)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "只有失败的任务才能重试")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryTaskNotRetryable(t *testing.T) {
|
||||||
|
cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
execution := &model.TaskExecution{
|
||||||
|
TaskID: "retry_not_allowed_001",
|
||||||
|
TaskType: testTaskType,
|
||||||
|
TaskName: "测试任务",
|
||||||
|
Status: model.TaskExecutionStatusFailed,
|
||||||
|
Retryable: false,
|
||||||
|
MaxRetry: 0,
|
||||||
|
TriggeredBy: "manual",
|
||||||
|
}
|
||||||
|
err := model.CreateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = RetryTask(ctx, execution.ID)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "不支持重试")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryTaskMaxRetryExceeded(t *testing.T) {
|
||||||
|
cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
execution := &model.TaskExecution{
|
||||||
|
TaskID: "retry_max_001",
|
||||||
|
TaskType: testTaskType,
|
||||||
|
TaskName: "测试任务",
|
||||||
|
Status: model.TaskExecutionStatusFailed,
|
||||||
|
Retryable: true,
|
||||||
|
MaxRetry: 2,
|
||||||
|
RetryCount: 2,
|
||||||
|
TriggeredBy: "retry",
|
||||||
|
}
|
||||||
|
err := model.CreateTaskExecution(ctx, execution)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = RetryTask(ctx, execution.ID)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "已达到最大重试次数")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRetryTaskNonExistent(t *testing.T) {
|
||||||
|
cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := RetryTask(ctx, 99999999)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateTaskID(t *testing.T) {
|
||||||
|
id1 := generateTaskID("test_type", "manual")
|
||||||
|
id2 := generateTaskID("test_type", "manual")
|
||||||
|
|
||||||
|
// 两个 ID 应不同(包含 Snowflake ID)
|
||||||
|
assert.NotEqual(t, id1, id2)
|
||||||
|
assert.Contains(t, id1, "manual_test_type_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateRetryTaskID(t *testing.T) {
|
||||||
|
id := generateRetryTaskID("original_task_123", 2)
|
||||||
|
assert.Equal(t, "retry_2_original_task_123", id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025-2026 linux.do
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package task
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// TaskResult 任务执行结果
|
||||||
|
type TaskResult struct {
|
||||||
|
Message string // 结果摘要,如 "共清理 120 个文件,耗时 3.2s"
|
||||||
|
Detail string // 可选的详细结果 JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskHandler 异步任务处理器接口
|
||||||
|
// 所有异步任务必须实现此接口,框架将自动管理任务执行记录的创建、状态流转和日志写入。
|
||||||
|
//
|
||||||
|
// 开发者只需实现 Execute 方法编写业务逻辑,在方法内通过 task.AppendLog(ctx, ...) 追加执行日志。
|
||||||
|
// 任务的创建、状态更新、错误记录、重试计数全部由框架透明处理。
|
||||||
|
type TaskHandler interface {
|
||||||
|
// Execute 执行任务业务逻辑
|
||||||
|
// - ctx: 已注入 Trace Span 和 taskID 的上下文
|
||||||
|
// - payload: 调度时传入的原始参数(可为 nil)
|
||||||
|
// - 返回 TaskResult 描述执行结果,或 error 表示执行失败
|
||||||
|
Execute(ctx context.Context, payload []byte) (*TaskResult, error)
|
||||||
|
}
|
||||||
@@ -28,13 +28,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
AsynqClient *asynq.Client
|
|
||||||
scheduler *asynq.Scheduler
|
scheduler *asynq.Scheduler
|
||||||
schedulerOnce sync.Once
|
schedulerOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
AsynqClient = asynq.NewClient(task.RedisOpt)
|
// AsynqClient 已在 task 包中初始化
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAsynqClient 获取全局 AsynqClient
|
||||||
|
func GetAsynqClient() *asynq.Client {
|
||||||
|
return task.AsynqClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartScheduler 启动调度器
|
// StartScheduler 启动调度器
|
||||||
|
|||||||
@@ -24,8 +24,12 @@ import (
|
|||||||
// RedisOpt asynq Redis 连接配置(兼容 Standalone/Sentinel/Cluster)
|
// RedisOpt asynq Redis 连接配置(兼容 Standalone/Sentinel/Cluster)
|
||||||
var RedisOpt asynq.RedisConnOpt
|
var RedisOpt asynq.RedisConnOpt
|
||||||
|
|
||||||
|
// AsynqClient asynq 客户端,用于任务入队
|
||||||
|
var AsynqClient *asynq.Client
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
RedisOpt = NewRedisConnOpt()
|
RedisOpt = NewRedisConnOpt()
|
||||||
|
AsynqClient = asynq.NewClient(RedisOpt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRedisConnOpt 根据配置返回对应的 asynq Redis 连接选项
|
// NewRedisConnOpt 根据配置返回对应的 asynq Redis 连接选项
|
||||||
|
|||||||
@@ -18,67 +18,15 @@ package worker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/linux-do/credit/internal/logger"
|
|
||||||
"go.opentelemetry.io/otel/trace"
|
|
||||||
|
|
||||||
"github.com/hibiken/asynq"
|
"github.com/hibiken/asynq"
|
||||||
"github.com/linux-do/credit/internal/otel_trace"
|
|
||||||
"go.opentelemetry.io/otel/attribute"
|
|
||||||
"go.opentelemetry.io/otel/codes"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// taskLoggingMiddleware 记录任务日志中间件
|
// taskLoggingMiddleware 任务处理中间件
|
||||||
|
// 注意:OTel Span 创建、日志记录、TaskExecution 状态管理
|
||||||
|
// 已由 task.ProcessTask 统一处理,此中间件保留用于未来扩展(如限流、监控等)
|
||||||
func taskLoggingMiddleware(h asynq.Handler) asynq.Handler {
|
func taskLoggingMiddleware(h asynq.Handler) asynq.Handler {
|
||||||
return asynq.HandlerFunc(func(ctx context.Context, t *asynq.Task) error {
|
return asynq.HandlerFunc(func(ctx context.Context, t *asynq.Task) error {
|
||||||
// 初始化 Trace
|
return h.ProcessTask(ctx, t)
|
||||||
ctx, span := otel_trace.Start(ctx, "TaskProcess_"+t.Type(), trace.WithSpanKind(trace.SpanKindConsumer))
|
|
||||||
defer span.End()
|
|
||||||
|
|
||||||
// 添加任务信息到 Span
|
|
||||||
span.SetAttributes(
|
|
||||||
attribute.String("task.type", t.Type()),
|
|
||||||
attribute.Int("task.payload_size", len(t.Payload())),
|
|
||||||
attribute.String("task.id", t.ResultWriter().TaskID()),
|
|
||||||
)
|
|
||||||
|
|
||||||
// 开始计时
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
// 处理任务
|
|
||||||
err := h.ProcessTask(ctx, t)
|
|
||||||
|
|
||||||
// 计算耗时
|
|
||||||
latency := time.Since(start)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
// 处理出错,记录错误日志
|
|
||||||
logger.ErrorF(
|
|
||||||
ctx,
|
|
||||||
"[TaskMiddleware] 任务处理失败 Type: %s\nStartTime: %s\nLatency: %d ms\nError: %v",
|
|
||||||
t.Type(),
|
|
||||||
start.Format(time.RFC3339),
|
|
||||||
latency.Milliseconds(),
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 设置 Span 错误状态
|
|
||||||
span.SetStatus(codes.Error, err.Error())
|
|
||||||
span.RecordError(err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理成功,记录成功日志
|
|
||||||
logger.InfoF(
|
|
||||||
ctx,
|
|
||||||
"[TaskMiddleware] 任务处理完成 Type: %s\nStartTime: %s\nEndTime: %s\nLatency: %d ms",
|
|
||||||
t.Type(),
|
|
||||||
start.Format(time.RFC3339),
|
|
||||||
time.Now().Format(time.RFC3339),
|
|
||||||
latency.Milliseconds(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
Copyright 2025 linux.do
|
Copyright 2025-2026 linux.do
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@@ -25,6 +25,11 @@ import (
|
|||||||
"github.com/linux-do/credit/internal/task"
|
"github.com/linux-do/credit/internal/task"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// 注册所有任务处理器
|
||||||
|
task.RegisterHandler(task.CleanupUnusedUploadsTask, &upload.CleanupUnusedUploadsHandler{})
|
||||||
|
}
|
||||||
|
|
||||||
// StartWorker 启动任务处理服务器
|
// StartWorker 启动任务处理服务器
|
||||||
func StartWorker() error {
|
func StartWorker() error {
|
||||||
asynqServer := asynq.NewServer(
|
asynqServer := asynq.NewServer(
|
||||||
@@ -37,10 +42,13 @@ func StartWorker() error {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// 注册任务处理器
|
// 注册 Asynq 任务路由
|
||||||
mux := asynq.NewServeMux()
|
mux := asynq.NewServeMux()
|
||||||
mux.Use(taskLoggingMiddleware)
|
mux.Use(taskLoggingMiddleware)
|
||||||
mux.HandleFunc(task.CleanupUnusedUploadsTask, upload.HandleCleanupUnusedUploads)
|
|
||||||
|
// 统一使用 task.ProcessTask 处理所有任务类型
|
||||||
|
// 框架内部自动分发到对应的 TaskHandler 实现
|
||||||
|
mux.HandleFunc(task.CleanupUnusedUploadsTask, task.ProcessTask)
|
||||||
|
|
||||||
// 启动服务器
|
// 启动服务器
|
||||||
return asynqServer.Run(mux)
|
return asynqServer.Run(mux)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
"github.com/hibiken/asynq"
|
"github.com/hibiken/asynq"
|
||||||
"github.com/linux-do/credit/internal/db"
|
"github.com/linux-do/credit/internal/db"
|
||||||
"github.com/linux-do/credit/internal/model"
|
"github.com/linux-do/credit/internal/model"
|
||||||
"github.com/linux-do/credit/internal/task/scheduler"
|
"github.com/linux-do/credit/internal/task"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -48,6 +48,7 @@ func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func())
|
|||||||
&model.ExternalAccount{},
|
&model.ExternalAccount{},
|
||||||
&model.SystemConfig{},
|
&model.SystemConfig{},
|
||||||
&model.Upload{},
|
&model.Upload{},
|
||||||
|
&model.TaskExecution{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to auto migrate tables: %v", err)
|
t.Fatalf("failed to auto migrate tables: %v", err)
|
||||||
@@ -69,7 +70,7 @@ func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func())
|
|||||||
db.Redis = redisClient
|
db.Redis = redisClient
|
||||||
|
|
||||||
// Hook up AsynqClient to miniredis
|
// Hook up AsynqClient to miniredis
|
||||||
scheduler.AsynqClient = asynq.NewClient(asynq.RedisClientOpt{
|
task.AsynqClient = asynq.NewClient(asynq.RedisClientOpt{
|
||||||
Addr: mr.Addr(),
|
Addr: mr.Addr(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func())
|
|||||||
// Reset database and Redis references
|
// Reset database and Redis references
|
||||||
db.SetDB(nil)
|
db.SetDB(nil)
|
||||||
db.Redis = nil
|
db.Redis = nil
|
||||||
scheduler.AsynqClient = nil
|
task.AsynqClient = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return sqliteDB, mr, cleanup
|
return sqliteDB, mr, cleanup
|
||||||
|
|||||||
Reference in New Issue
Block a user