mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 16:46:37 +08:00
feat(framework): 回灌 OpenFlare 分层、安全与运行时改进
将平台域持久化收敛为 repository 唯一入口,model 去掉 IO。 邮件头写入前清除 CR/LF,防止 header 注入。 httppool 支持可配置 Transport;batchwriter 增加 MinBatchSize/Stats,flush 失败交回批次;任务 PermanentError 作为 SkipRetry 终态。 设置与推送页的确认改为 AlertDialog;axios 去尾斜杠并按 Gin 数组序列化查询参数。 升级共享 Go 依赖(Gin、Asynq、OTel、GORM、Redis 等)。
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/persistence/idgen"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
otel_trace "github.com/Rain-kl/Wavelet/pkg/trace"
|
||||
"github.com/hibiken/asynq"
|
||||
@@ -110,7 +111,7 @@ func AppendLog(ctx context.Context, format string, args ...interface{}) {
|
||||
}
|
||||
|
||||
logLine := fmt.Sprintf(format, args...)
|
||||
if err := model.AppendTaskExecutionLog(ctx, taskID, logLine); err != nil {
|
||||
if err := repository.AppendTaskExecutionLog(ctx, taskID, logLine); err != nil {
|
||||
logger.ErrorF(ctx, "[TaskExecutor] 追加任务日志失败 taskID=%s: %v", taskID, err)
|
||||
}
|
||||
}
|
||||
@@ -138,7 +139,7 @@ func DispatchTask(ctx context.Context, taskType string, payload []byte, triggere
|
||||
TriggeredBy: triggeredBy,
|
||||
}
|
||||
|
||||
if err := model.CreateTaskExecution(ctx, execution); err != nil {
|
||||
if err := repository.CreateTaskExecution(ctx, execution); err != nil {
|
||||
return "", fmt.Errorf(errCreateTaskExecutionFailed, err)
|
||||
}
|
||||
|
||||
@@ -156,11 +157,11 @@ func DispatchTask(ctx context.Context, taskType string, payload []byte, triggere
|
||||
now := time.Now()
|
||||
execution.StartedAt = &now
|
||||
execution.FinishedAt = &now
|
||||
_ = model.UpdateTaskExecution(ctx, execution)
|
||||
_ = repository.UpdateTaskExecution(ctx, execution)
|
||||
return "", fmt.Errorf(errTaskEnqueueFailed, err)
|
||||
}
|
||||
|
||||
if err := model.AppendTaskExecutionLog(ctx, taskID, fmt.Sprintf("[系统] 任务已成功入队,等待调度执行 (队列: %s, 最大重试次数: %d)", meta.Queue, meta.MaxRetry)); err != nil {
|
||||
if err := repository.AppendTaskExecutionLog(ctx, taskID, fmt.Sprintf("[系统] 任务已成功入队,等待调度执行 (队列: %s, 最大重试次数: %d)", meta.Queue, meta.MaxRetry)); err != nil {
|
||||
logger.ErrorF(ctx, "[TaskExecutor] 追加入队日志失败 taskID=%s: %v", taskID, err)
|
||||
}
|
||||
|
||||
@@ -169,7 +170,7 @@ func DispatchTask(ctx context.Context, taskType string, payload []byte, triggere
|
||||
|
||||
// RetryTask 重试失败的任务
|
||||
func RetryTask(ctx context.Context, id uint64) (string, error) {
|
||||
execution, err := model.GetTaskExecutionByID(ctx, id)
|
||||
execution, err := repository.GetTaskExecutionByID(ctx, id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(errTaskExecutionNotFound, err)
|
||||
}
|
||||
@@ -198,7 +199,7 @@ func RetryTask(ctx context.Context, id uint64) (string, error) {
|
||||
TriggeredBy: "retry",
|
||||
}
|
||||
|
||||
if err := model.CreateTaskExecution(ctx, newExecution); err != nil {
|
||||
if err := repository.CreateTaskExecution(ctx, newExecution); err != nil {
|
||||
return "", fmt.Errorf(errCreateRetryExecutionFailed, err)
|
||||
}
|
||||
|
||||
@@ -221,11 +222,11 @@ func RetryTask(ctx context.Context, id uint64) (string, error) {
|
||||
now := time.Now()
|
||||
newExecution.StartedAt = &now
|
||||
newExecution.FinishedAt = &now
|
||||
_ = model.UpdateTaskExecution(ctx, newExecution)
|
||||
_ = repository.UpdateTaskExecution(ctx, newExecution)
|
||||
return "", fmt.Errorf(errRetryTaskEnqueueFailed, err)
|
||||
}
|
||||
|
||||
if err := model.AppendTaskExecutionLog(ctx, newTaskID, fmt.Sprintf("[系统] 手动触发重试,已重新创建任务并入队 (原任务ID: %s, 重试次数: %d/%d)", execution.TaskID, execution.RetryCount+1, execution.MaxRetry)); err != nil {
|
||||
if err := repository.AppendTaskExecutionLog(ctx, newTaskID, fmt.Sprintf("[系统] 手动触发重试,已重新创建任务并入队 (原任务ID: %s, 重试次数: %d/%d)", execution.TaskID, execution.RetryCount+1, execution.MaxRetry)); err != nil {
|
||||
logger.ErrorF(ctx, "[TaskExecutor] 追加重试日志失败 taskID=%s: %v", newTaskID, err)
|
||||
}
|
||||
|
||||
@@ -350,7 +351,7 @@ func updateExecutionOnStart(ctx context.Context, execution *model.TaskExecution,
|
||||
dirty = true
|
||||
}
|
||||
if dirty {
|
||||
if updateErr := model.UpdateTaskExecution(ctx, execution); updateErr != nil {
|
||||
if updateErr := repository.UpdateTaskExecution(ctx, execution); updateErr != nil {
|
||||
logger.ErrorF(ctx, "[TaskExecutor] 更新执行状态失败 taskID=%s: %v", execution.TaskID, updateErr)
|
||||
}
|
||||
}
|
||||
@@ -358,7 +359,7 @@ func updateExecutionOnStart(ctx context.Context, execution *model.TaskExecution,
|
||||
|
||||
// getOrCreateTaskExecution 获取已有的任务执行记录,如果不存在则针对已知任务类型动态创建记录
|
||||
func getOrCreateTaskExecution(ctx context.Context, taskID string, t *asynq.Task, payload []byte, now time.Time) (*model.TaskExecution, error) {
|
||||
execution, err := model.GetTaskExecutionByTaskID(ctx, taskID)
|
||||
execution, err := repository.GetTaskExecutionByTaskID(ctx, taskID)
|
||||
if err == nil {
|
||||
return execution, nil
|
||||
}
|
||||
@@ -381,7 +382,7 @@ func getOrCreateTaskExecution(ctx context.Context, taskID string, t *asynq.Task,
|
||||
StartedAt: &now,
|
||||
}
|
||||
|
||||
if createErr := model.CreateTaskExecution(ctx, execution); createErr != nil {
|
||||
if createErr := repository.CreateTaskExecution(ctx, execution); createErr != nil {
|
||||
logger.ErrorF(ctx, "[TaskExecutor] 动态创建执行记录失败 taskID=%s: %v", taskID, createErr)
|
||||
return nil, createErr
|
||||
}
|
||||
@@ -404,11 +405,11 @@ func completeTaskExecution(ctx context.Context, execution *model.TaskExecution,
|
||||
handleSuccessfulTask(ctx, execution, t, duration, result)
|
||||
}
|
||||
|
||||
if err := model.UpdateTaskExecution(ctx, execution); err != nil {
|
||||
if err := repository.UpdateTaskExecution(ctx, execution); err != nil {
|
||||
logger.ErrorF(ctx, "[TaskExecutor] 更新执行记录失败 taskID=%s: %v", execution.TaskID, err)
|
||||
}
|
||||
if shouldFlushTaskExecutionLog(ctx, execErr) {
|
||||
if err := model.FlushTaskExecutionLog(ctx, execution.TaskID); err != nil {
|
||||
if err := repository.FlushTaskExecutionLog(ctx, execution.TaskID); err != nil {
|
||||
logger.ErrorF(ctx, "[TaskExecutor] 持久化任务日志失败 taskID=%s: %v", execution.TaskID, err)
|
||||
}
|
||||
}
|
||||
@@ -428,7 +429,7 @@ func notifyTaskCompleted(ctx context.Context, execution *model.TaskExecution, re
|
||||
}
|
||||
|
||||
func shouldFlushTaskExecutionLog(ctx context.Context, execErr error) bool {
|
||||
if execErr == nil {
|
||||
if isTerminalTaskExecutionError(execErr) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -440,6 +441,10 @@ func shouldFlushTaskExecutionLog(ctx context.Context, execErr error) bool {
|
||||
return retryCount >= maxRetry
|
||||
}
|
||||
|
||||
func isTerminalTaskExecutionError(execErr error) bool {
|
||||
return execErr == nil || errors.Is(execErr, asynq.SkipRetry)
|
||||
}
|
||||
|
||||
func handleFailedTask(ctx context.Context, execution *model.TaskExecution, t *asynq.Task, duration time.Duration, execErr error, span trace.Span) {
|
||||
execution.Status = model.TaskExecutionStatusFailed
|
||||
execution.ErrorMessage = execErr.Error()
|
||||
|
||||
@@ -6,11 +6,13 @@ package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -120,7 +122,7 @@ func TestAppendLogWithTaskID(t *testing.T) {
|
||||
Status: model.TaskExecutionStatusRunning,
|
||||
TriggeredBy: "manual",
|
||||
}
|
||||
err := model.CreateTaskExecution(ctx, execution)
|
||||
err := repository.CreateTaskExecution(ctx, execution)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 注入 taskID 并追加日志
|
||||
@@ -129,7 +131,7 @@ func TestAppendLogWithTaskID(t *testing.T) {
|
||||
AppendLog(ctx, "处理了 %d 条数据", 50)
|
||||
|
||||
// 验证日志
|
||||
found, err := model.GetTaskExecutionByTaskID(ctx, "log_test_001")
|
||||
found, err := repository.GetTaskExecutionByTaskID(ctx, "log_test_001")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, found.Log, "第一条日志")
|
||||
assert.Contains(t, found.Log, "处理了 50 条数据")
|
||||
@@ -188,7 +190,7 @@ func TestProcessTaskSuccess(t *testing.T) {
|
||||
MaxRetry: 3,
|
||||
TriggeredBy: "manual",
|
||||
}
|
||||
err := model.CreateTaskExecution(ctx, execution)
|
||||
err := repository.CreateTaskExecution(ctx, execution)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 通过 asynq 的 Task 不能直接设置 taskID,ProcessTask 通过 t.ResultWriter().TaskID() 获取
|
||||
@@ -206,7 +208,7 @@ func TestProcessTaskSuccess(t *testing.T) {
|
||||
assert.Equal(t, "处理完成,共 100 条", result.Message)
|
||||
|
||||
// 验证日志被追加
|
||||
found, err := model.GetTaskExecutionByTaskID(ctx, "process_success_001")
|
||||
found, err := repository.GetTaskExecutionByTaskID(ctx, "process_success_001")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, found.Log, "执行成功,处理了 100 条数据")
|
||||
}
|
||||
@@ -229,7 +231,7 @@ func TestProcessTaskFailure(t *testing.T) {
|
||||
MaxRetry: 3,
|
||||
TriggeredBy: "manual",
|
||||
}
|
||||
err := model.CreateTaskExecution(ctx, execution)
|
||||
err := repository.CreateTaskExecution(ctx, execution)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 直接调用 handler
|
||||
@@ -242,7 +244,7 @@ func TestProcessTaskFailure(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "模拟执行失败")
|
||||
|
||||
// 验证日志
|
||||
found, err := model.GetTaskExecutionByTaskID(ctx, "process_fail_001")
|
||||
found, err := repository.GetTaskExecutionByTaskID(ctx, "process_fail_001")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, found.Log, "开始执行任务")
|
||||
}
|
||||
@@ -259,7 +261,7 @@ func TestCompleteTaskExecutionFlushesLog(t *testing.T) {
|
||||
Status: model.TaskExecutionStatusRunning,
|
||||
TriggeredBy: "manual",
|
||||
}
|
||||
err := model.CreateTaskExecution(ctx, execution)
|
||||
err := repository.CreateTaskExecution(ctx, execution)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx = withTaskID(ctx, execution.TaskID)
|
||||
@@ -277,13 +279,18 @@ func TestCompleteTaskExecutionFlushesLog(t *testing.T) {
|
||||
trace.SpanFromContext(ctx),
|
||||
)
|
||||
|
||||
found, err := model.GetTaskExecutionByTaskID(ctx, execution.TaskID)
|
||||
found, err := repository.GetTaskExecutionByTaskID(ctx, execution.TaskID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.TaskExecutionStatusSucceeded, found.Status)
|
||||
assert.Contains(t, found.Log, "任务执行中的日志")
|
||||
assert.Contains(t, found.Log, "任务执行成功")
|
||||
}
|
||||
|
||||
func TestPermanentErrorIsTerminalForLogFlush(t *testing.T) {
|
||||
assert.True(t, isTerminalTaskExecutionError(PermanentError("配置无效")))
|
||||
assert.False(t, isTerminalTaskExecutionError(errors.New("temporary failure")))
|
||||
}
|
||||
|
||||
func TestRetryTask(t *testing.T) {
|
||||
cleanup := setupTest(t)
|
||||
defer cleanup()
|
||||
@@ -305,7 +312,7 @@ func TestRetryTask(t *testing.T) {
|
||||
Duration: 100,
|
||||
TriggeredBy: "manual",
|
||||
}
|
||||
err := model.CreateTaskExecution(ctx, execution)
|
||||
err := repository.CreateTaskExecution(ctx, execution)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 重试
|
||||
@@ -315,7 +322,7 @@ func TestRetryTask(t *testing.T) {
|
||||
assert.Contains(t, newTaskID, "retry_1_")
|
||||
|
||||
// 验证新记录
|
||||
newExecution, err := model.GetTaskExecutionByTaskID(ctx, newTaskID)
|
||||
newExecution, err := repository.GetTaskExecutionByTaskID(ctx, newTaskID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.TaskExecutionStatusPending, newExecution.Status)
|
||||
assert.Equal(t, 1, newExecution.RetryCount)
|
||||
@@ -324,7 +331,7 @@ func TestRetryTask(t *testing.T) {
|
||||
assert.True(t, newExecution.Retryable)
|
||||
|
||||
// 原记录不变
|
||||
original, err := model.GetTaskExecutionByID(ctx, execution.ID)
|
||||
original, err := repository.GetTaskExecutionByID(ctx, execution.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.TaskExecutionStatusFailed, original.Status)
|
||||
assert.Equal(t, 0, original.RetryCount)
|
||||
@@ -345,7 +352,7 @@ func TestRetryTaskNotFailed(t *testing.T) {
|
||||
MaxRetry: 3,
|
||||
TriggeredBy: "manual",
|
||||
}
|
||||
err := model.CreateTaskExecution(ctx, execution)
|
||||
err := repository.CreateTaskExecution(ctx, execution)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 尝试重试成功的任务
|
||||
@@ -368,7 +375,7 @@ func TestRetryTaskNotRetryable(t *testing.T) {
|
||||
MaxRetry: 0,
|
||||
TriggeredBy: "manual",
|
||||
}
|
||||
err := model.CreateTaskExecution(ctx, execution)
|
||||
err := repository.CreateTaskExecution(ctx, execution)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = RetryTask(ctx, execution.ID)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
)
|
||||
|
||||
const defaultPermanentErrorMessage = "任务无法继续执行"
|
||||
|
||||
type permanentTaskError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
// PermanentError marks a safe domain message as a non-retryable task failure.
|
||||
// It intentionally accepts no underlying error so Error never exposes provider,
|
||||
// URL, header, response-body, or other sensitive implementation details.
|
||||
func PermanentError(message string) error {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
message = defaultPermanentErrorMessage
|
||||
}
|
||||
return &permanentTaskError{message: message}
|
||||
}
|
||||
|
||||
func (e *permanentTaskError) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e *permanentTaskError) Unwrap() error {
|
||||
return asynq.SkipRetry
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPermanentErrorSkipsRetryWithoutExposingAsynqMessage(t *testing.T) {
|
||||
err := PermanentError(" 来源配置无效 ")
|
||||
|
||||
assert.True(t, errors.Is(err, asynq.SkipRetry))
|
||||
assert.Equal(t, "来源配置无效", err.Error())
|
||||
assert.NotContains(t, err.Error(), asynq.SkipRetry.Error())
|
||||
}
|
||||
|
||||
func TestPermanentErrorUsesSafeFallbackForBlankMessage(t *testing.T) {
|
||||
err := PermanentError(" ")
|
||||
|
||||
assert.True(t, errors.Is(err, asynq.SkipRetry))
|
||||
assert.Equal(t, defaultPermanentErrorMessage, err.Error())
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/infra/task"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
"github.com/Rain-kl/Wavelet/internal/platform/bootstrap"
|
||||
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
@@ -84,7 +84,7 @@ func ReloadScheduler() error {
|
||||
}
|
||||
|
||||
// 2. 从数据库载入启用的定时任务配置
|
||||
schedules, err := model.ListActiveSchedules(context.Background())
|
||||
schedules, err := repository.ListActiveSchedules(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("load schedules from db failed: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user