Skip to content

fix: prevent duplicate suno task refunds via cas status update - #6074

Merged
Calcium-Ion merged 2 commits into
QuantumNous:mainfrom
feitianbubu:pr/b5241e091
Jul 20, 2026
Merged

fix: prevent duplicate suno task refunds via cas status update#6074
Calcium-Ion merged 2 commits into
QuantumNous:mainfrom
feitianbubu:pr/b5241e091

Conversation

@feitianbubu

@feitianbubu feitianbubu commented Jul 10, 2026

Copy link
Copy Markdown
Member

📝 变更描述 / Description

Suno 轮询里失败退款原来是直接调 RefundTaskQuota,没有并发保护。重叠轮询、超时 sweep、多实例同时命中有机率触发重复退款。

改法:同步视频任务的 CAS保护

🚀 变更类型 / Type of change

  • 🐛 Bug 修复 (Bug fix) - 请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug
  • ✨ 新功能 (New feature) - 重大特性建议先通过 Issue 沟通
  • ⚡ 性能优化 / 重构 (Refactor)
  • 📝 文档更新 (Documentation)

🔗 关联任务 / Related Issue

✅ 提交前检查项 / Checklist

  • 人工确认: 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
  • 非重复提交: 我已搜索现有的 IssuesPRs,确认不是重复提交。
  • Bug fix 说明: 若此 PR 标记为 Bug fix,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。
  • 变更理解: 我已理解这些更改的工作原理及可能影响。
  • 范围聚焦: 本 PR 未包含任何与当前任务无关的代码改动。
  • 本地验证: 已在本地运行并通过测试或手动验证,维护者可以据此复核结果。
  • 安全合规: 代码中无敏感凭据,且符合项目代码规范。

📸 运行证明 / Proof of Work

触发一次suno 生成失败任务, 只退款一次
image

Summary by CodeRabbit

  • Bug Fixes
    • Prevented conflicting task finalizations by using CAS-style, winner-only status persistence and warning on no-ops/losses.
    • Improved quota-refund reconciliation for failed and timed-out tasks, including grace-period handling and legacy cutoff rules.
    • Updated refund behavior to report success/failure and to clear persisted quota only after successful refund funding.
  • New Features
    • Added unrefunded-failed task sweeping with concurrency-safe refund claiming and marker restoration.
    • Refined async polling enablement to keep reconciliation running when needed.
  • Tests
    • Added CAS/idempotency, refund claiming/restoration, selection filtering, and Suno polling/refund tests.

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Task polling now reconciles refundable failed tasks with CAS-protected quota markers. Timeout and platform finalization paths gate refunds on winning state transitions, while billing preserves pending markers after funding failures. Scheduler work detection includes pending refunds.

Changes

Task refund reconciliation

Layer / File(s) Summary
Refund eligibility and CAS contracts
model/task.go, model/task_cas_test.go
Adds legacy-task filtering, refundable-task queries, polling-work detection, and atomic claim/restore helpers with integration tests.
Refund result and quota marker persistence
service/task_billing.go, service/task_billing_test.go
Refunds report funding failures, clear persisted quota after success, and retain quota markers when retry is required.
Timeout and failed-task reconciliation
service/task_polling.go, controller/system_task_handlers.go
Timeout handling uses CAS, legacy tasks are excluded from refunds, and polling reconciles failed tasks through claim-and-restore markers.
Platform task finalization
service/task_polling.go, service/task_polling_test.go
Suno finalization uses CAS-based status updates and winning-transition refund gates; related polling tests cover idempotency and retry behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PlatformResult
  participant updateSunoTasks
  participant TaskPersistence
  participant RefundTaskQuota
  PlatformResult->>updateSunoTasks: provide status and fail reason
  updateSunoTasks->>TaskPersistence: UpdateWithStatus(prevStatus)
  TaskPersistence-->>updateSunoTasks: CAS result
  updateSunoTasks->>RefundTaskQuota: refund after winning failure transition
Loading

Possibly related PRs

Suggested reviewers: calcium-ion

Poem

A rabbit watched the task stream flow,
While CAS kept refunds in a row.
Failed tasks claim, then hop to retry,
Legacy ones quietly pass by.
“No double refunds!” the bunny sings.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the core change: preventing duplicate Suno task refunds using CAS status updates.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

JacksonsY added a commit to JacksonsY/new-api that referenced this pull request Jul 19, 2026
移植自上游 PR QuantumNous#6074。Suno 轮询原先「先退款、后无条件 Update」,
重叠轮询/sweep/多实例场景下同一失败任务会退多次款。改为与本
fork 其余任务路径同构的 UpdateWithStatus(prevStatus) CAS:只有
状态迁移的赢者才执行 RefundTaskQuota,且仅在旧状态非 FAILURE
且 Quota 非零时退款。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
service/task_billing.go (1)

165-203: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Refund→marker-clear ordering leaves a residual duplicate-refund window.

RefundTaskQuota performs the real funding/token refund first, then clears the persisted quota marker with a plain, non-CAS task.UpdateQuota() at the end. That write failing (logged, but still returns true) leaves a nonzero quota marker on an already-refunded task. sweepUnrefundedFailedTasks in service/task_polling.go (Lines 101-133) has no way to distinguish "never refunded" from "refunded but marker-write failed" — it will re-claim the marker and call RefundTaskQuota again, issuing a second real refund.

This gap only matters for callers that invoke RefundTaskQuota directly without pre-claiming via model.ClaimQuotaForRefund — i.e. sweepTimedOutTasks (service/task_polling.go Lines 88-90), updateSunoTasks (service/task_polling.go Lines 347-349), and updateVideoSingleTask (service/task_polling.go Lines 638-639). The sweep path itself is safe because it already zeroes the marker via ClaimQuotaForRefund before calling RefundTaskQuota, making the trailing UpdateQuota() a no-op there.

Consider having RefundTaskQuota itself claim (zero) the quota marker atomically before performing the external refund, and restore it via RestoreQuotaAfterFailedRefund only if the refund fails — mirroring the pattern already implemented in sweepUnrefundedFailedTasks. That closes the gap for every caller uniformly and would let the sweep function drop its now-redundant claim/restore logic. Adding a regression test simulating a successful refund with a failing final quota write would also be valuable given this is exactly the failure mode the PR targets.

🛡️ Illustrative direction (not a full patch)
 func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) bool {
 	quota := task.Quota
 	if quota == 0 {
 		return true
 	}

+	claimed, err := model.ClaimQuotaForRefund(task.ID, quota)
+	if err != nil {
+		logger.LogError(ctx, fmt.Sprintf("claim quota marker failed task %s: %s", task.TaskID, err.Error()))
+		return false
+	}
+	if !claimed {
+		return true // already claimed/refunded elsewhere
+	}
+
 	// 1. 退还资金来源(钱包或订阅)
 	if err := taskAdjustFunding(task, -quota); err != nil {
 		logger.LogWarn(ctx, fmt.Sprintf("退还资金来源失败 task %s: %s", task.TaskID, err.Error()))
+		if _, restoreErr := model.RestoreQuotaAfterFailedRefund(task.ID, quota); restoreErr != nil {
+			logger.LogError(ctx, fmt.Sprintf("restore quota marker failed task %s: %s", task.TaskID, restoreErr.Error()))
+		}
 		return false
 	}
 	...
-	task.Quota = 0
-	if err := task.UpdateQuota(); err != nil {
-		logger.LogError(ctx, fmt.Sprintf("退款成功但清除 task quota 失败 task %s: %s", task.TaskID, err.Error()))
-	}
+	task.Quota = 0
 	return true
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@service/task_billing.go` around lines 165 - 203, Update RefundTaskQuota to
atomically claim and clear the task quota marker via the existing
ClaimQuotaForRefund before performing any funding or token refund; restore it
with RestoreQuotaAfterFailedRefund when the refund fails, and return without
refunding if the claim is unsuccessful. Remove the redundant claim/restore flow
from sweepUnrefundedFailedTasks so it delegates marker handling to
RefundTaskQuota, while preserving successful refund logging and final quota
persistence behavior.
🧹 Nitpick comments (1)
model/task.go (1)

311-332: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Dedup the shared "non-legacy unrefunded failure" filter.

The status = FAILURE AND quota != 0 AND (submit_time <= 0 OR submit_time >= TaskRefundLegacyCutoff) predicate is duplicated verbatim between GetUnrefundedFailedTasks and HasTaskPollingWork. Extracting a shared scope avoids the two definitions silently drifting apart in a future edit. These queries also run on every poll cycle and scheduler Enabled() check (via controller/system_task_handlers.go) — worth double-checking there's a supporting index (e.g. on status+quota, or status+updated_at) so this doesn't become a full-table scan hotspot as the tasks table grows.

♻️ Proposed dedup
+func unrefundedFailureScope(db *gorm.DB) *gorm.DB {
+	return db.Where("status = ?", TaskStatusFailure).
+		Where("quota != ?", 0).
+		Where("(submit_time <= ? OR submit_time >= ?)", 0, TaskRefundLegacyCutoff)
+}
+
 func GetUnrefundedFailedTasks(updatedBefore int64, limit int) []*Task {
 	if limit <= 0 {
 		return nil
 	}

 	var tasks []*Task
-	err := DB.Where("status = ?", TaskStatusFailure).
-		Where("quota != ?", 0).
-		Where("updated_at <= ?", updatedBefore).
-		Where("(submit_time <= ? OR submit_time >= ?)", 0, TaskRefundLegacyCutoff).
+	err := unrefundedFailureScope(DB).
+		Where("updated_at <= ?", updatedBefore).
 		Order("id").
 		Limit(limit).
 		Find(&tasks).Error
 	if err != nil {
 		return nil
 	}
 	return tasks
 }
 func HasTaskPollingWork() bool {
 	if HasUnfinishedSyncTasks() {
 		return true
 	}

 	var id int64
-	err := DB.Model(&Task{}).
-		Where("status = ?", TaskStatusFailure).
-		Where("quota != ?", 0).
-		Where("(submit_time <= ? OR submit_time >= ?)", 0, TaskRefundLegacyCutoff).
+	err := unrefundedFailureScope(DB.Model(&Task{})).
 		Limit(1).
 		Pluck("id", &id).Error
 	return err == nil && id != 0
 }

Also applies to: 359-376

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@model/task.go` around lines 311 - 332, Extract the shared non-legacy
unrefunded-failure predicate into a reusable query scope and apply it in both
GetUnrefundedFailedTasks and HasTaskPollingWork, preserving their additional
conditions. Check the task schema/index definitions and add or reuse a
supporting index for these polling queries, following the project’s migration
conventions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@service/task_billing.go`:
- Around line 165-203: Update RefundTaskQuota to atomically claim and clear the
task quota marker via the existing ClaimQuotaForRefund before performing any
funding or token refund; restore it with RestoreQuotaAfterFailedRefund when the
refund fails, and return without refunding if the claim is unsuccessful. Remove
the redundant claim/restore flow from sweepUnrefundedFailedTasks so it delegates
marker handling to RefundTaskQuota, while preserving successful refund logging
and final quota persistence behavior.

---

Nitpick comments:
In `@model/task.go`:
- Around line 311-332: Extract the shared non-legacy unrefunded-failure
predicate into a reusable query scope and apply it in both
GetUnrefundedFailedTasks and HasTaskPollingWork, preserving their additional
conditions. Check the task schema/index definitions and add or reuse a
supporting index for these polling queries, following the project’s migration
conventions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 06be9f75-51dd-4794-a154-05a0b17a583c

📥 Commits

Reviewing files that changed from the base of the PR and between f41b3e9 and b1a42bd.

📒 Files selected for processing (7)
  • controller/system_task_handlers.go
  • model/task.go
  • model/task_cas_test.go
  • service/task_billing.go
  • service/task_billing_test.go
  • service/task_polling.go
  • service/task_polling_test.go

@Calcium-Ion
Calcium-Ion merged commit e0d5156 into QuantumNous:main Jul 20, 2026
1 check passed
qpwo005451 pushed a commit to qpwo005451/new-api that referenced this pull request Jul 21, 2026
…umNous#6074)

* fix: prevent duplicate suno task refunds via cas status update

* fix: reconcile failed task refunds

---------

Co-authored-by: CaIon <i@caion.me>
JacksonsY added a commit to JacksonsY/new-api that referenced this pull request Jul 22, 2026
同步上游 10 个提交(至 1721144),重点整合:

- QuantumNous#6329 鉴权重构:dashboard 会话全面改为无状态 token(access/refresh
  + 版本栅栏 + 会话管理),gin session 全部移除。fork 侧适配:
  - turnstile 一次性消费改为按 token 键控内存缓存(兼容发码+注册两步流)
  - TRUSTED_PROXY_CIDRS 作为 TRUSTED_PROXIES 的兼容别名保留
  - UserBase/ToBaseUser 保留 ParentId(子号计费),补 AuthVersion/CacheSchema
  - OAuth 绑定改 flow_token 流,保留 GitHub 账号年龄门禁(消费 flow 后校验)
  - RecordUserIP 反欺诈埋点移入 setupLoginAtAuthVersion
  - 子号/代理鉴权门(SubPermission/RejectSubAccount/AgentAuth)原样保留
  - profile 嫁接上游 LoginSessionsCard(会话管理 UI),绑定卡接入
    popup+postMessage 新绑定机制
  - 2FA/OAuth/微信登录后 redirect 目标经 handleLoginSuccess 传递恢复
- web/default → web/ 扁平化 + 删除 classic 主题:fork 全部前端定制
  (agent/supplier/detector/sub-account 等 180+ 文件)迁移至新路径,
  保留 fork 的 i18n 按需加载、每表分页记忆、主题调校与设计系统
- QuantumNous#6157 渠道代理客户端重构(别名缓存+失效清理),保留 fork 全局代理
  与 RELAY_DISABLE_HTTP2;QuantumNous#6074 suno CAS 防重复退款;QuantumNous#6163 playground
  自动分组;QuantumNous#6224 无限额度密钥显示已用量;QuantumNous#6032 realtime GA 去 beta 头
- 语言文件三方合并:fork ~6280 键 + 上游新增 55 键鉴权文案
- fork 刻意删除的组件与 workflows 维持删除(上次合并曾误恢复)

验证:go build/test 全绿,前端 tsgo 类型检查与 rsbuild 构建通过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
yiranxiaohui added a commit to yiranxiaohui/new-api that referenced this pull request Jul 25, 2026
Sync 14 upstream commits. The dominant change is structural: upstream
promoted the frontend from web/default to the web/ root and deleted the
classic theme entirely, so most of the diff is renames and deletions.

Notable upstream work:
- refactor(auth): stateless dashboard tokens replacing sessions (QuantumNous#6329)
- feat(channel): upstream model discovery for Codex and advanced custom
  channels (QuantumNous#6184, QuantumNous#5971)
- fix: CAS status update prevents duplicate suno task refunds (QuantumNous#6074)
- fix: no duplicate tool calls in Responses-to-Chat streaming (QuantumNous#6225)

Fork-side resolutions:
- Drop the classic theme, following upstream. electron/ and the
  release/electron-build workflows stay deleted as this fork already
  removed them; GHCR publishing continues via docker-build.yml.
- UserBase keeps the fork's per-user Ratio alongside upstream's new Role,
  AuthVersion and CacheSchema fields. GetUserCache adopts upstream's
  cache-population path, which returns ToBaseUser() and so still carries
  Ratio.
- web-router keeps the fork's dynamic index injector (SystemName/Logo
  templating) on top of upstream's renamed frontendFS. serveIndex collapses
  to the single-frontend WebAssets now that classic is gone.
- Restore the fork's invoices feature (5 files), which git's rename
  detection dropped during the web/default -> web/ move, and re-register
  its route in routeTree.gen.ts.

Backend builds and the full Go test suite passes. The frontend is not yet
type-checked or built locally.

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
zhaodechao2008 pushed a commit to zhaodechao2008/new-api that referenced this pull request Jul 27, 2026
…umNous#6074)

* fix: prevent duplicate suno task refunds via cas status update

* fix: reconcile failed task refunds

---------

Co-authored-by: CaIon <i@caion.me>
lizhongyi1209 pushed a commit to lizhongyi1209/new-api that referenced this pull request Jul 28, 2026
…umNous#6074)

* fix: prevent duplicate suno task refunds via cas status update

* fix: reconcile failed task refunds

---------

Co-authored-by: CaIon <i@caion.me>
330079598 pushed a commit to 330079598/new-api that referenced this pull request Aug 19, 2026
…umNous#6074)

* fix: prevent duplicate suno task refunds via cas status update

* fix: reconcile failed task refunds

---------

Co-authored-by: CaIon <i@caion.me>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants