From a3197e214646565aea516251579070f9ba8a21b8 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:21:43 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=AE=B0=E8=B4=A6=E5=90=8C=E4=B8=80=20t?= =?UTF-8?q?race=20=E9=98=B2=E9=87=8D=E5=A4=8D=E6=89=A3=E6=AC=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 流水 ref_id 有唯一索引,但扣款本身没有:重试/重复 flush 场景下 同一 trace 会被重复扣钱。billUser 扣款前先查 ref_id 是否已存在。 在远端记账重写基础上合并防重检查与测试。 --- server/internal/usage/usage.go | 7 +++++++ server/internal/usage/usage_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/server/internal/usage/usage.go b/server/internal/usage/usage.go index 4f80dc3..61518ec 100644 --- a/server/internal/usage/usage.go +++ b/server/internal/usage/usage.go @@ -158,6 +158,13 @@ func (r *Recorder) billUser(tx *gorm.DB, l *store.UsageLog) error { return err } // 余额允许为负(流式请求不中断;后续请求被 402 拒),所以 WHERE 只按 id 匹配 + // 同一 trace 已记过账(重试/重复 flush)则跳过:流水 ref_id 有唯一索引, + // 但扣款本身没有,不挡住会重复扣钱 + var billed int64 + tx.Model(&store.BalanceLog{}).Where("ref_id = ?", usageRefID(l.TraceID)).Count(&billed) + if billed > 0 { + return nil + } res := tx.Model(&store.User{}).Where("id = ?", l.UserID).Update("balance", gorm.Expr("balance - ?", l.Cost)) if res.Error != nil { return res.Error diff --git a/server/internal/usage/usage_test.go b/server/internal/usage/usage_test.go index 8926673..773f7fb 100644 --- a/server/internal/usage/usage_test.go +++ b/server/internal/usage/usage_test.go @@ -141,3 +141,32 @@ func seedModel(t *testing.T, db *gorm.DB) uint64 { } func strPtr(s string) *string { return &s } + +// 同一 trace 重复记账(重试/重复 flush 场景)只扣一次款:流水 ref_id 有唯一索引, +// 但扣款本身没有,billUser 必须先查再扣。 +func TestBillingRefIDImpotency(t *testing.T) { + db := openTestDB(t) + u := seedUser(t, db, 5) + + rec := NewRecorder(db) + mk := func(trace string) *store.UsageLog { + return &store.UsageLog{TraceID: trace, UserID: u, ModelName: "m1", + Status: store.UsageStatusSuccess, Cost: 1, CreatedAt: time.Now().UTC()} + } + rec.Record(mk("dup")) + rec.Record(mk("dup")) + rec.Close() + + var user store.User + if err := db.First(&user, u).Error; err != nil { + t.Fatal(err) + } + if user.Balance != 4 { + t.Errorf("balance = %v, want 4 (重复 trace 不重复扣款)", user.Balance) + } + var n int64 + db.Model(&store.BalanceLog{}).Where("user_id = ?", u).Count(&n) + if n != 1 { + t.Errorf("balance logs = %d, want 1", n) + } +}