From 4e570389dd433a717373ce9c9b822b59f5ed3d5d Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:21:00 +0800 Subject: [PATCH] fix: use GORM v2 row locking for subscription resets (#6057) * fix: use GORM v2 row locking for subscription resets * docs: codify GORM row locking rules --- AGENTS.md | 1 + model/subscription.go | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cbd781b2..8f41dcf7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,7 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag - Prefer GORM methods (`Create`, `Find`, `Where`, `Updates`, etc.) over raw SQL. - Let GORM handle primary key generation; do not use `AUTO_INCREMENT` or `SERIAL` directly. +- Standard `SELECT ... FOR UPDATE` row locks built with GORM query methods in `model/` MUST use `lockForUpdate(tx)`. Do not use the legacy GORM v1 pattern `tx.Set("gorm:query_option", "FOR UPDATE")`, because GORM v2 silently ignores it and no lock is acquired. Do not duplicate `clause.Locking{Strength: "UPDATE"}` at call sites; the shared helper emits `FOR UPDATE` for MySQL/PostgreSQL and skips it for SQLite, where the syntax is unsupported. Dialect-specific locking with different semantics (for example, a MySQL next-key/gap lock) may use raw SQL only behind explicit database-type branches with valid fallbacks for every supported database. - When raw SQL is unavoidable, account for dialect differences: - PostgreSQL uses `"column"` quoting, while MySQL/SQLite use `` `column` ``. - Use `commonGroupCol`, `commonKeyCol` from `model/main.go` for reserved-word columns like `group` and `key`. diff --git a/model/subscription.go b/model/subscription.go index b8ae9774..642c7b03 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -1027,7 +1027,7 @@ func adminResetUserSubscriptionsByPlanTx(tx *gorm.DB, userId int, plan *Subscrip return nil, errors.New("invalid reset args") } var subs []UserSubscription - if err := tx.Set("gorm:query_option", "FOR UPDATE"). + if err := lockForUpdate(tx). Where("user_id = ? AND plan_id = ? AND status = ? AND end_time > ?", userId, plan.Id, "active", now). Order("end_time asc, id asc"). Find(&subs).Error; err != nil { @@ -1049,7 +1049,7 @@ func adminResetPlanSubscriptionsTx(tx *gorm.DB, plan *SubscriptionPlan, now int6 return nil, errors.New("invalid reset args") } var subs []UserSubscription - if err := tx.Set("gorm:query_option", "FOR UPDATE"). + if err := lockForUpdate(tx). Where("plan_id = ? AND status = ? AND end_time > ?", plan.Id, "active", now). Order("user_id asc, end_time asc, id asc"). Find(&subs).Error; err != nil {