feat: 新增周期性交易处理器及任务队列,实现周期性交易的自动处理、调度和幂等性保障。

This commit is contained in:
2026-01-28 16:38:48 +08:00
parent 5ff680ee43
commit 297d2715b8
2 changed files with 33 additions and 12 deletions

View File

@@ -39,12 +39,27 @@ func NewRecurringTransactionHandler(
}
// Handle 处理周期性交易任务
// 遵循 v7.0 规范:并发安全与幂等性
func (h *RecurringTransactionHandler) Handle(ctx context.Context, task *DelayedTask) error {
payload, err := ParseRecurringPayload(task)
if err != nil {
return fmt.Errorf("failed to parse payload: %w", err)
}
// 幂等性检查:防止同一笔交易重复执行
// 使用 Redis SETNX 锁,锁 24 小时(足以覆盖重试窗口)
// TaskID 本身包含 recurringID 和执行时间戳,是天然的幂等 Key
lockKey := fmt.Sprintf("novault:lock:recurring:%s", task.ID)
// 使用 TaskQueue 提供的公开方法获取锁
isNew, err := h.taskQueue.AcquireLock(ctx, lockKey, 24*time.Hour)
if err != nil {
return fmt.Errorf("failed to check idempotency: %w", err)
}
if !isNew {
log.Printf("[RecurringHandler] Task %s already processed (idempotency check), skipping", task.ID)
return nil
}
log.Printf("[RecurringHandler] Processing recurring transaction %d for user %d",
payload.RecurringTransactionID, task.UserID)
@@ -94,23 +109,24 @@ func (h *RecurringTransactionHandler) Handle(ctx context.Context, task *DelayedT
return fmt.Errorf("failed to create transaction: %w", err)
}
// 更新账户余额
var account models.Account
if err := tx.First(&account, recurring.AccountID).Error; err != nil {
tx.Rollback()
return fmt.Errorf("failed to get account: %w", err)
}
// 更新账户余额 - 使用原子操作修复并发安全问题 (Race Condition Fix)
// UPDATE accounts SET balance = balance +/- ? WHERE id = ?
var updateExpr interface{}
switch recurring.Type {
case models.TransactionTypeIncome:
account.Balance += recurring.Amount
updateExpr = gorm.Expr("balance + ?", recurring.Amount)
case models.TransactionTypeExpense:
account.Balance -= recurring.Amount
updateExpr = gorm.Expr("balance - ?", recurring.Amount)
default:
tx.Rollback()
return fmt.Errorf("unknown transaction type: %s", recurring.Type)
}
if err := tx.Save(&account).Error; err != nil {
if err := tx.Model(&models.Account{}).
Where("id = ?", recurring.AccountID).
Update("balance", updateExpr).Error; err != nil {
tx.Rollback()
return fmt.Errorf("failed to update account balance: %w", err)
return fmt.Errorf("failed to update account balance atomic: %w", err)
}
// 计算下一次执行时间
@@ -140,7 +156,7 @@ func (h *RecurringTransactionHandler) Handle(ctx context.Context, task *DelayedT
if recurring.IsActive {
if err := h.scheduleNext(ctx, recurring); err != nil {
log.Printf("[RecurringHandler] Warning: failed to schedule next execution: %v", err)
// 不返回错误,当前任务已成功完成
// 不返回错误,当前任务已成功完成,仅仅是下次调度失败(会有补偿机制兜底)
}
}

View File

@@ -218,3 +218,8 @@ func (q *TaskQueue) GetPendingTasks(ctx context.Context, limit int64) ([]Delayed
return tasks, nil
}
// AcquireLock 尝试获取分布式锁(用于幂等性检查)
func (q *TaskQueue) AcquireLock(ctx context.Context, key string, ttl time.Duration) (bool, error) {
return q.client.SetNX(ctx, key, "1", ttl).Result()
}