Skip to content

feat: 新增普通充值自动升级分组规则配置#3409

Open
guoruqiang wants to merge 12 commits intoQuantumNous:mainfrom
guoruqiang:feature/topup-auto-switch-group
Open

feat: 新增普通充值自动升级分组规则配置#3409
guoruqiang wants to merge 12 commits intoQuantumNous:mainfrom
guoruqiang:feature/topup-auto-switch-group

Conversation

@guoruqiang
Copy link
Contributor

@guoruqiang guoruqiang commented Mar 23, 2026

变更说明

  • 为普通充值新增“按累计成功充值金额自动切换分组”能力。
  • 新增配置项:
    • payment_setting.auto_switch_group_enabled
    • payment_setting.auto_switch_group_rules
    • payment_setting.auto_switch_group_only_new_topups
    • payment_setting.auto_switch_group_base_group
  • 规则统一按 USD 阈值存储和匹配,命中时取不超过累计充值总额的最高阈值对应分组。
  • 支持“仅统计新充值”,开启后只统计启用后的新成功普通充值,不回溯历史充值。
  • 支持“基础分组”约束,自动切组仅作用于配置的分组链路内,不覆盖链外分组。
  • 仅对普通充值生效,不影响 subscription 购买时的 upgrade_group 逻辑。
  • 前端按当前显示单位编辑阈值,保存时统一换算为 USD。

变更类型

  • Bug 修复
  • 新功能
  • 重构
  • 文档更新

关联任务

  • Closes #

验证摘要

当前测试规则

  • 1 USD -> user1
  • 10 USD -> vip
  • 100 USD -> svip

已完成验证

  • 10 用户回归矩阵:
    覆盖 default / user1 / vip / svip 初始分组、无历史充值、有历史充值、跨阈值累计、Stripe 累计、Creem + Epay 混合累计、重复补单幂等,以及 active subscription 用户再次普通充值。

  • 结果:Passed 10/10,Failed 0/10

  • 边界与配置校验:
    覆盖重复阈值、非法分组、启用状态下空规则、failed / expired 历史订单不参与累计、订阅失效后的回退、active subscription 保护、关闭自动切换、单规则配置、单规则未达阈值、未命中任何阈值。

  • 结果:Passed 10/10,Failed 0/10

  • 补充验证:
    覆盖“仅统计新充值”、基础分组约束、金额边界、状态流转、幂等、负向校验,以及订阅优先级与回退。

  • 重点验证:
    1 / 10 / 100 USD 精确命中、failed / expired 订单不能补成成功、同一订单并发重复完成、active subscription 不会被普通充值覆盖、subscription 失效后会回退到正确分组、链外分组不会被自动切组覆盖。

结论

  • 普通充值已支持按累计成功充值金额自动切换分组。
  • Stripe / Creem / Epay 的累计语义符合预期。
  • 同一订单重复完成保持幂等。
  • subscription upgrade_group 不会被普通充值覆盖。
  • subscription 失效后会回退到正确分组。
  • 支持“仅统计新充值”与“基础分组”约束。
  • 配置校验与边界场景行为正常。
image

Summary by CodeRabbit

  • New Features

    • Automatic user-group switching after top-ups with configurable threshold→group rules, base group selection, and display-currency conversion.
    • New payment settings UI: enable toggle, “only count new top-ups” option (activation cutoff), rule editor, and validation requiring at least one valid rule to enable.
    • Added translations for the new settings in multiple languages.
  • Bug Fixes

    • Hardened and simplified payment completion flows (including Epay) for more reliable processing.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 23, 2026

Walkthrough

Implements a payment auto-switch group feature: new API and option normalization for auto-switch rules, persistent payment_setting batch updates, top-up completion flow that applies auto-switch group logic, subscription group-resolution changes, frontend rule editor/UI, and comprehensive tests for auto-switch behavior.

Changes

Cohort / File(s) Summary
Payment Auto-Switch API & Controller
controller/option.go, router/api-router.go
Added UpdatePaymentAutoSwitchGroup handler and PaymentAutoSwitchGroupUpdateRequest; validation/normalization for base group, rules, enabled/only-new-topups and enabled_from behavior; registered PUT /api/option/payment_auto_switch_group.
Top-up Controller & Flow
controller/topup.go
Replaced inline Epay completion with RechargeEpay call; removed logger quota call; adapted handler to use centralized recharge helper and simplified response paths.
Top-up Model & Auto-Switch Logic
model/topup.go, model/topup_auto_switch_test.go
Added normalization of top-up USD, cumulative top-up summation, auto-switch rule matching, transactional group update helpers (completeTopUpTx, completeTopUpByTradeNo), GetUserSuccessfulTopupTotalUSDTx, NormalizeTopUpValueUSD, RechargeEpay, and comprehensive tests validating switching, only-new-topups, and expiry fallback behavior.
Option Management & Persistence
model/option.go
Replaced single-key update with UpdateOptions(map[string]string) batch API; deterministic key ordering, transactionally upserted options, special deferred handling/validation for payment_setting.* keys via validatePaymentSettingOptionValues and applyPaymentSettingOptionValues; UpdateOption delegates to batch API.
Subscription Group Resolution
model/subscription.go
Added tx-scoped helpers (getLatestActiveSubscriptionUpgradeTx, GetActiveSubscriptionUpgradeGroupTx, resolveUserEffectiveGroupTx) and refactored downgrade/expire flows to compute target group via resolved effective group and use updateUserGroupTx.
PaymentSetting Config & Concurrency
setting/operation_setting/payment_setting.go
Extended PaymentSetting with auto-switch fields and PaymentAutoSwitchGroupRule type; added RW mutex, GetPaymentSetting() returns deep copy, and UpdatePaymentSetting(mutator) for synchronized updates and base-group normalization.
Frontend: Payment Settings UI
web/src/components/settings/PaymentSetting.jsx, web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx
Added UI state and editor for auto-switch settings and rules, display-unit↔USD conversion helpers, validation/sorting/serialization of rules, group option loading, and conditional API submission to the new endpoint.
Frontend: i18n
web/src/i18n/locales/*.json
Added English, French, Japanese, Russian, Vietnamese, Simplified/Traditional Chinese translation entries for the auto-switch feature and validations.

Sequence Diagram(s)

sequenceDiagram
    participant User as User/Admin
    participant Frontend as Frontend UI
    participant API as Controller
    participant Model as Model Layer
    participant PaymentSetting as PaymentSetting<br/>(Operation Config)
    participant DB as Database

    User->>Frontend: Configure auto-switch rules<br/>(base group, thresholds)
    Frontend->>Frontend: Validate rules & convert<br/>display unit → USD
    User->>Frontend: Submit configuration
    Frontend->>API: PUT /api/option/payment_auto_switch_group
    API->>API: Normalize & validate request<br/>(base group, rules, thresholds)
    API->>Model: UpdateOptions(map of<br/>payment_setting.* keys)
    Model->>DB: Transaction: Upsert all<br/>option key/values
    DB-->>Model: Options saved
    Model->>PaymentSetting: applyPaymentSettingOptionValues
    PaymentSetting->>PaymentSetting: UpdatePaymentSetting(mutator)<br/>- Lock & update struct<br/>- Normalize base_group<br/>- Return deep copy
    Model->>Model: Update in-memory OptionMap for payment_setting.* keys
    Model-->>API: Success
    API-->>Frontend: 200 OK
Loading
sequenceDiagram
    participant User as User Performs<br/>Top-up
    participant API as Controller
    participant Model as Model Layer
    participant PaymentSetting as PaymentSetting<br/>(Config)
    participant DB as Database
    participant UserModel as User Model

    User->>API: Submit top-up (Epay)
    API->>Model: RechargeEpay(tradeNo)
    Model->>Model: completeTopUpByTradeNo()<br/>- Lock row (FOR UPDATE)
    Model->>DB: Mark top-up as Success
    Model->>DB: Update User quota/balance
    Model->>PaymentSetting: GetPaymentSetting()
    PaymentSetting-->>Model: Auto-switch config & rules
    Model->>Model: NormalizeTopUpValueUSD()
    Model->>Model: GetUserSuccessfulTopupTotalUSDTx()
    Model->>Model: Match total vs rules → target group
    alt in controlled chain and no active subscription override
        Model->>UserModel: Update User.Group = target group
        Model->>DB: Save User changes
    end
    Model-->>API: CompleteTopUp returns
    API->>API: Update cache if group changed
    API-->>User: Success
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • alpha -> main #1865: Modifies option loading/updating logic and interacts with model/option.go special-key handling.

Suggested reviewers

  • Calcium-Ion
  • seefs001
  • creamlike1024

Poem

🐰 I nibble thresholds, tidy and spry,

Coins stack up beneath the sky,
Rules sorted neat, groups hop in line,
Auto-switch blooms — a carrot design! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.41% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: 新增普通充值自动升级分组规则配置' accurately describes the main feature addition—automatic group switching for ordinary top-ups based on configuration rules. It is specific, concise, and clearly conveys the primary change.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🧹 Nitpick comments (1)
setting/operation_setting/payment_setting.go (1)

43-60: Extract the deep-copy logic into one helper.

The clone path is duplicated in both accessors. If PaymentSetting gains another slice/map field later, it is easy to update one copy site and miss the other, which would quietly reintroduce shared mutable state.

♻️ Suggested refactor
+func clonePaymentSetting(src PaymentSetting) PaymentSetting {
+	cloned := src
+	if src.AmountOptions != nil {
+		cloned.AmountOptions = append([]int(nil), src.AmountOptions...)
+	}
+	if src.AmountDiscount != nil {
+		cloned.AmountDiscount = make(map[int]float64, len(src.AmountDiscount))
+		for amount, discount := range src.AmountDiscount {
+			cloned.AmountDiscount[amount] = discount
+		}
+	}
+	if src.AutoSwitchGroupRules != nil {
+		cloned.AutoSwitchGroupRules = append([]PaymentAutoSwitchGroupRule(nil), src.AutoSwitchGroupRules...)
+	}
+	return cloned
+}
+
 func GetPaymentSetting() PaymentSetting {
 	paymentSettingRWMutex.RLock()
 	defer paymentSettingRWMutex.RUnlock()
-
-	copiedSetting := paymentSetting
-	if paymentSetting.AmountOptions != nil {
-		copiedSetting.AmountOptions = append([]int(nil), paymentSetting.AmountOptions...)
-	}
-	if paymentSetting.AmountDiscount != nil {
-		copiedSetting.AmountDiscount = make(map[int]float64, len(paymentSetting.AmountDiscount))
-		for amount, discount := range paymentSetting.AmountDiscount {
-			copiedSetting.AmountDiscount[amount] = discount
-		}
-	}
-	if paymentSetting.AutoSwitchGroupRules != nil {
-		copiedSetting.AutoSwitchGroupRules = append([]PaymentAutoSwitchGroupRule(nil), paymentSetting.AutoSwitchGroupRules...)
-	}
-	return copiedSetting
+	return clonePaymentSetting(paymentSetting)
 }

Also applies to: 78-90

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@setting/operation_setting/payment_setting.go` around lines 43 - 60, Extract
the deep-copy code into a single helper function (e.g., ClonePaymentSetting or
copyPaymentSetting) that accepts a PaymentSetting and returns a fully
deep-copied PaymentSetting; move the existing slice/map copy logic
(AmountOptions, AmountDiscount, AutoSwitchGroupRules and any future slice/map
fields) into that helper, then update GetPaymentSetting to call the helper while
retaining the paymentSettingRWMutex.RLock/RUnlock; do the same for the other
accessor that duplicates this logic so both call the new helper to avoid
divergent copy implementations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@model/option.go`:
- Around line 221-247: The current DB.Transaction block (DB.Transaction) commits
before in-memory validation and application (updateOptionMap and
applyPaymentSettingOptionValues), which can leave partial updates if validation
fails; move the validation/application logic inside the same transaction so the
whole multi-key update is atomic: perform updateOptionMap checks and
collect/validate paymentSettingOptionValues within the transaction callback
(alongside FirstOrCreate/Save for Option), and call
applyPaymentSettingOptionValues from inside that transaction so any error causes
tx rollback and is returned to the caller; ensure
applyPaymentSettingOptionValues returns/propagates errors so DB.Transaction can
roll back.
- Around line 260-272: The code stores the raw input for
auto_switch_group_base_group into common.OptionMap instead of the normalized
value produced via UpdatePaymentSetting; update the flow so the normalized
base-group is written back to common.OptionMap (and ideally normalized before
persisting to DB) by retrieving the normalized value from the PaymentSetting
after operation_setting.UpdatePaymentSetting (or from
paymentSettingConfigMap/UpdateConfigFromMap result) and assigning that
normalized string to common.OptionMap["auto_switch_group_base_group"] instead of
the original raw value; adjust code around
operation_setting.UpdatePaymentSetting, paymentSettingConfigMap,
UpdateConfigFromMap, and common.OptionMap to ensure storage, runtime and UI all
use the same normalized value.

In `@model/topup.go`:
- Around line 616-617: Replace the inconsistent logger call in the RecordLog
invocation: currently it uses logger.LogQuota(...) but other recharge paths use
logger.FormatQuota(...); update the RecordLog call that references topUp.UserId,
LogTypeTopup and topUp.Money to call logger.FormatQuota(quotaToAdd) instead of
logger.LogQuota(quotaToAdd) so the log formatting is consistent with the other
recharge flows.
- Around line 293-315: The transaction only locks the top-up row and can race
when multiple top-ups for the same user commit concurrently; before calling
completeTopUpTx(tx, &topUp, userUpdates) acquire a per-user lock to serialize
completions for that user (e.g. a DB advisory lock or SELECT ... FOR UPDATE on
the users row) using the user ID from topUp, hold the lock for the duration of
the transaction, then call completeTopUpTx and release the lock at transaction
end; update the code around the transaction block that references topUp, tx,
userUpdatesBuilder and completeTopUpTx to acquire and release this per-user lock
so recomputation of cumulative tiers and writes to users.group are serialized.
- Around line 294-295: The SELECT using a FOR UPDATE lock
(tx.Set("gorm:query_option", "FOR UPDATE").Where(...).First(&topUp)) is
incompatible with SQLite; guard it by checking common.UsingSQLite and only apply
the FOR UPDATE option when not using SQLite (i.e., if !common.UsingSQLite {
tx.Set("gorm:query_option", "FOR UPDATE")... } ), otherwise perform the plain
SELECT and rely on the existing manual rollback/serial approach used in
model/checkin.go; apply the same pattern to the similar usages in
model/subscription.go, model/user.go, and model/redemption.go to ensure
cross-database compatibility.

In `@setting/operation_setting/payment_setting.go`:
- Line 25: paymentSetting is subject to a data race because ExportAllConfigs →
ConfigToMap uses reflection to read struct fields without locking
paymentSettingRWMutex while UpdatePaymentSetting writes to AmountDiscount and
AutoSwitchGroupRules; also deep-copy logic is duplicated in GetPaymentSetting
and UpdatePaymentSetting. Fix by centralizing safe copying and ensuring
reflections read the protected copy: add a helper function (e.g.,
clonePaymentSetting or copyPaymentSetting) that takes the lock, makes a deep
copy of paymentSetting (including proper deep copies of AmountDiscount and
AutoSwitchGroupRules), and returns it; replace the duplicated deep-copy code in
GetPaymentSetting and UpdatePaymentSetting with calls to this helper; change
ExportAllConfigs/ConfigToMap to call GetPaymentSetting or clonePaymentSetting
(so reflection operates on an immutable copy) instead of directly reflecting
over paymentSetting.

In `@web/src/components/settings/PaymentSetting.jsx`:
- Around line 109-115: The JSON.parse of item.value that assigns to
newInputs['AutoSwitchGroupRules'] may return non-array values; after parsing
inside the try block (the code that sets newInputs['AutoSwitchGroupRules']),
validate the parsed result with Array.isArray and only assign it if true,
otherwise set newInputs['AutoSwitchGroupRules'] = []; keep the existing catch to
handle parse errors but add the Array.isArray check for the parsed value to
prevent non-array objects from being stored.

In `@web/src/i18n/locales/fr.json`:
- Around line 158-171: Add the missing French translation for the new error key
used in SettingsPaymentGateway.jsx: add an entry for the Chinese key
"分组加载失败,请刷新后重试" to web/src/i18n/locales/fr.json with an appropriate French
message (e.g., "Échec du chargement des groupes, veuillez actualiser et
réessayer") so t('分组加载失败,请刷新后重试') returns a French string instead of falling
back to Chinese.

In `@web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx`:
- Around line 339-349: handleFormChange currently normalizes inputs into React
state only, leaving the Semi Form store stale; update the form store with the
same normalizedValues when a change is accepted. Inside handleFormChange (after
computing normalizedValues and confirming compareObjects(prev,
normalizedValues).length !== 0), call the Semi Form API to set the form values
(e.g., formApi.setValues or formRef.current.setValues) with normalizedValues so
the form store and React state stay in sync, taking care to only call the form
setter when values actually changed to avoid infinite loops.

---

Nitpick comments:
In `@setting/operation_setting/payment_setting.go`:
- Around line 43-60: Extract the deep-copy code into a single helper function
(e.g., ClonePaymentSetting or copyPaymentSetting) that accepts a PaymentSetting
and returns a fully deep-copied PaymentSetting; move the existing slice/map copy
logic (AmountOptions, AmountDiscount, AutoSwitchGroupRules and any future
slice/map fields) into that helper, then update GetPaymentSetting to call the
helper while retaining the paymentSettingRWMutex.RLock/RUnlock; do the same for
the other accessor that duplicates this logic so both call the new helper to
avoid divergent copy implementations.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e558997a-a6bd-4544-ac15-047bb355905c

📥 Commits

Reviewing files that changed from the base of the PR and between 9ae9040 and b1d7230.

📒 Files selected for processing (17)
  • controller/option.go
  • controller/topup.go
  • model/option.go
  • model/subscription.go
  • model/topup.go
  • model/topup_auto_switch_test.go
  • router/api-router.go
  • setting/operation_setting/payment_setting.go
  • web/src/components/settings/PaymentSetting.jsx
  • web/src/i18n/locales/en.json
  • web/src/i18n/locales/fr.json
  • web/src/i18n/locales/ja.json
  • web/src/i18n/locales/ru.json
  • web/src/i18n/locales/vi.json
  • web/src/i18n/locales/zh-CN.json
  • web/src/i18n/locales/zh-TW.json
  • web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx

Comment on lines +221 to +247
if err := DB.Transaction(func(tx *gorm.DB) error {
for _, key := range keys {
option := Option{Key: key}
if err := tx.FirstOrCreate(&option, Option{Key: key}).Error; err != nil {
return err
}
option.Value = optionValues[key]
if err := tx.Save(&option).Error; err != nil {
return err
}
}
return nil
}); err != nil {
return err
}

paymentSettingOptionValues := make(map[string]string)
for _, key := range keys {
if strings.HasPrefix(key, "payment_setting.") {
paymentSettingOptionValues[key] = optionValues[key]
continue
}
if err := updateOptionMap(key, optionValues[key]); err != nil {
return err
}
}
return applyPaymentSettingOptionValues(paymentSettingOptionValues)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

UpdateOptions can return an error after the batch is already committed.

Line 221 commits the transaction before Lines 243-247 validate and apply the values in memory. If a later key is malformed, the caller gets a failure after some rows are already saved and earlier globals may already be mutated, so multi-key updates lose all-or-nothing semantics.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model/option.go` around lines 221 - 247, The current DB.Transaction block
(DB.Transaction) commits before in-memory validation and application
(updateOptionMap and applyPaymentSettingOptionValues), which can leave partial
updates if validation fails; move the validation/application logic inside the
same transaction so the whole multi-key update is atomic: perform
updateOptionMap checks and collect/validate paymentSettingOptionValues within
the transaction callback (alongside FirstOrCreate/Save for Option), and call
applyPaymentSettingOptionValues from inside that transaction so any error causes
tx rollback and is returned to the caller; ensure
applyPaymentSettingOptionValues returns/propagates errors so DB.Transaction can
roll back.

Comment on lines +260 to 272
var paymentSettingErr error
operation_setting.UpdatePaymentSetting(func(setting *operation_setting.PaymentSetting) {
paymentSettingErr = config.UpdateConfigFromMap(setting, paymentSettingConfigMap)
})
if paymentSettingErr != nil {
return paymentSettingErr
}

common.OptionMapRWMutex.Lock()
defer common.OptionMapRWMutex.Unlock()
for key, value := range optionValues {
common.OptionMap[key] = value
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Write back the normalized base-group value, not the raw input.

Lines 261-263 normalize auto_switch_group_base_group through UpdatePaymentSetting, but Line 271 still stores the pre-normalized string in common.OptionMap. Saving "" or whitespace makes runtime behavior fall back to "default" while the config API/UI reads back blank, so operators see a different value from the one actually enforced.

🛠️ Focused fix
-	var paymentSettingErr error
-	operation_setting.UpdatePaymentSetting(func(setting *operation_setting.PaymentSetting) {
+	var paymentSettingErr error
+	normalizedPaymentSetting := operation_setting.UpdatePaymentSetting(func(setting *operation_setting.PaymentSetting) {
 		paymentSettingErr = config.UpdateConfigFromMap(setting, paymentSettingConfigMap)
 	})
 	if paymentSettingErr != nil {
 		return paymentSettingErr
 	}
@@
 	common.OptionMapRWMutex.Lock()
 	defer common.OptionMapRWMutex.Unlock()
 	for key, value := range optionValues {
+		if key == "payment_setting.auto_switch_group_base_group" {
+			common.OptionMap[key] = normalizedPaymentSetting.AutoSwitchGroupBaseGroup
+			continue
+		}
 		common.OptionMap[key] = value
 	}

At minimum, mirror the normalized value into OptionMap; ideally normalize before the DB write too so storage, runtime, and UI stay consistent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model/option.go` around lines 260 - 272, The code stores the raw input for
auto_switch_group_base_group into common.OptionMap instead of the normalized
value produced via UpdatePaymentSetting; update the flow so the normalized
base-group is written back to common.OptionMap (and ideally normalized before
persisting to DB) by retrieving the normalized value from the PaymentSetting
after operation_setting.UpdatePaymentSetting (or from
paymentSettingConfigMap/UpdateConfigFromMap result) and assigning that
normalized string to common.OptionMap["auto_switch_group_base_group"] instead of
the original raw value; adjust code around
operation_setting.UpdatePaymentSetting, paymentSettingConfigMap,
UpdateConfigFromMap, and common.OptionMap to ensure storage, runtime and UI all
use the same normalized value.

Comment on lines +293 to 315
err := DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&topUp).Error; err != nil {
return errors.New("充值订单不存在")
}

if topUp.Status == common.TopUpStatusSuccess {
return nil
}
if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}

topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
err = tx.Save(topUp).Error
if err != nil {
return err
userUpdates := map[string]interface{}{}
if userUpdatesBuilder != nil {
updates, err := userUpdatesBuilder(&topUp, tx)
if err != nil {
return err
}
userUpdates = updates
}

quota = topUp.Money * common.QuotaPerUnit
err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}).Error
var err error
switchedGroup, err = completeTopUpTx(tx, &topUp, userUpdates)
if err != nil {
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Serialize completions per user before recomputing cumulative tiers.

This transaction only locks the current top-up row. If two different top-ups for the same user finish at the same time, each aggregation can miss the other's uncommitted success and both writes can leave the user at the lower matched group. Add a per-user lock/mutex before completeTopUpTx() recomputes the total and updates users.group.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model/topup.go` around lines 293 - 315, The transaction only locks the top-up
row and can race when multiple top-ups for the same user commit concurrently;
before calling completeTopUpTx(tx, &topUp, userUpdates) acquire a per-user lock
to serialize completions for that user (e.g. a DB advisory lock or SELECT ...
FOR UPDATE on the users row) using the user ID from topUp, hold the lock for the
duration of the transaction, then call completeTopUpTx and release the lock at
transaction end; update the code around the transaction block that references
topUp, tx, userUpdatesBuilder and completeTopUpTx to acquire and release this
per-user lock so recomputation of cumulative tiers and writes to users.group are
serialized.

Comment on lines +294 to 295
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&topUp).Error; err != nil {
return errors.New("充值订单不存在")
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C2 'gorm:query_option|FOR UPDATE|UsingSQLite' model/topup.go model

Repository: QuantumNous/new-api

Length of output: 7178


Add SQLite compatibility guard for FOR UPDATE lock.

FOR UPDATE is not supported in SQLite. The code at line 294 will fail at runtime when using SQLite. Add a check like if !common.UsingSQLite to either skip locking for SQLite or use an alternative mechanism (e.g., sequential operations with manual rollback as done in model/checkin.go).

Note: Similar unsupported FOR UPDATE patterns exist in model/subscription.go, model/user.go, and model/redemption.go — consider a comprehensive fix across all model files.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model/topup.go` around lines 294 - 295, The SELECT using a FOR UPDATE lock
(tx.Set("gorm:query_option", "FOR UPDATE").Where(...).First(&topUp)) is
incompatible with SQLite; guard it by checking common.UsingSQLite and only apply
the FOR UPDATE option when not using SQLite (i.e., if !common.UsingSQLite {
tx.Set("gorm:query_option", "FOR UPDATE")... } ), otherwise perform the plain
SELECT and rely on the existing manual rollback/serial approach used in
model/checkin.go; apply the same pattern to the similar usages in
model/subscription.go, model/user.go, and model/redemption.go to ensure
cross-database compatibility.

Comment on lines +616 to +617
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money))
return topUp, true, nil
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'func (LogQuota|FormatQuota)|\bLogQuota\(|\bFormatQuota\('

Repository: QuantumNous/new-api

Length of output: 4458


Align logging function with other recharge paths in this file.

Line 616 uses logger.LogQuota(...) while other recharge operations in model/topup.go (lines 350, 519, 587) use logger.FormatQuota(...). Both functions are exported, but align this call with the established pattern for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model/topup.go` around lines 616 - 617, Replace the inconsistent logger call
in the RecordLog invocation: currently it uses logger.LogQuota(...) but other
recharge paths use logger.FormatQuota(...); update the RecordLog call that
references topUp.UserId, LogTypeTopup and topUp.Money to call
logger.FormatQuota(quotaToAdd) instead of logger.LogQuota(quotaToAdd) so the log
formatting is consistent with the other recharge flows.

AutoSwitchGroupRules []PaymentAutoSwitchGroupRule `json:"auto_switch_group_rules"`
}

var paymentSettingRWMutex sync.RWMutex
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether payment_setting is ever accessed outside the wrapper helpers.
# Any direct registry read/write path is a mutex bypass and needs review.
rg -n -C2 --type=go 'Register\("payment_setting"|Get\("payment_setting"\)|ExportAllConfigs\('

Repository: QuantumNous/new-api

Length of output: 931


🏁 Script executed:

#!/bin/bash
# Examine config.go to understand how Register and ExportAllConfigs work
fd -e go "config.go" | xargs wc -l | sort -n

Repository: QuantumNous/new-api

Length of output: 291


🏁 Script executed:

# Find and examine the Register method in config.go
rg -A 15 "func \(cm \*ConfigManager\) Register" --type=go

Repository: QuantumNous/new-api

Length of output: 858


🏁 Script executed:

# Find and examine the ExportAllConfigs method implementation
rg -A 20 "func \(cm \*ConfigManager\) ExportAllConfigs" --type=go

Repository: QuantumNous/new-api

Length of output: 939


🏁 Script executed:

# Check for direct field accesses to paymentSetting fields (AmountDiscount, AutoSwitchGroupRules)
rg -n "AmountDiscount|AutoSwitchGroupRules" --type=go | head -20

Repository: QuantumNous/new-api

Length of output: 2456


🏁 Script executed:

# Find ConfigToMap implementation
rg -A 15 "func ConfigToMap" --type=go

Repository: QuantumNous/new-api

Length of output: 912


🏁 Script executed:

# Examine the context of model/topup.go:88
sed -n '80,95p' model/topup.go

Repository: QuantumNous/new-api

Length of output: 641


🏁 Script executed:

# Check if model/topup.go has any synchronization around these accesses
rg -B 5 -A 5 "paymentSetting.AutoSwitchGroupRules" model/topup.go

Repository: QuantumNous/new-api

Length of output: 895


🏁 Script executed:

# Find all callers of buildPaymentAutoSwitchGroupChainSet
rg -B 10 "buildPaymentAutoSwitchGroupChainSet" --type=go | head -40

Repository: QuantumNous/new-api

Length of output: 1433


🏁 Script executed:

# Find the private configToMap implementation
rg -A 30 "func configToMap" --type=go | head -50

Repository: QuantumNous/new-api

Length of output: 1429


The mutex doesn't fully protect paymentSetting from concurrent access.

While GetPaymentSetting() and UpdatePaymentSetting() are protected by paymentSettingRWMutex, the struct is also accessed via ExportAllConfigs()ConfigToMap() which uses reflection to read struct fields directly without holding the mutex. This creates a data race: concurrent ConfigToMap reflection reads can race against UpdatePaymentSetting writes to AmountDiscount and AutoSwitchGroupRules.

Additionally, the deep-copy logic for these two fields is duplicated in both GetPaymentSetting() (lines 51–58) and UpdatePaymentSetting() (lines 82–89), increasing maintenance risk.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@setting/operation_setting/payment_setting.go` at line 25, paymentSetting is
subject to a data race because ExportAllConfigs → ConfigToMap uses reflection to
read struct fields without locking paymentSettingRWMutex while
UpdatePaymentSetting writes to AmountDiscount and AutoSwitchGroupRules; also
deep-copy logic is duplicated in GetPaymentSetting and UpdatePaymentSetting. Fix
by centralizing safe copying and ensuring reflections read the protected copy:
add a helper function (e.g., clonePaymentSetting or copyPaymentSetting) that
takes the lock, makes a deep copy of paymentSetting (including proper deep
copies of AmountDiscount and AutoSwitchGroupRules), and returns it; replace the
duplicated deep-copy code in GetPaymentSetting and UpdatePaymentSetting with
calls to this helper; change ExportAllConfigs/ConfigToMap to call
GetPaymentSetting or clonePaymentSetting (so reflection operates on an immutable
copy) instead of directly reflecting over paymentSetting.

Comment on lines +109 to +115
try {
newInputs['AutoSwitchGroupRules'] = JSON.parse(
item.value || '[]',
);
} catch (error) {
newInputs['AutoSwitchGroupRules'] = [];
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

auto_switch_group_rules 增加陣列型別校驗。

Line 110 目前只做 JSON.parse,若值是合法但非陣列 JSON(如物件),會把非陣列寫入 AutoSwitchGroupRules,可能導致後續規則渲染/遍歷異常。

🔧 Proposed fix
           case 'payment_setting.auto_switch_group_rules':
             try {
-              newInputs['AutoSwitchGroupRules'] = JSON.parse(
-                item.value || '[]',
-              );
+              const parsed = JSON.parse(item.value || '[]');
+              newInputs['AutoSwitchGroupRules'] = Array.isArray(parsed)
+                ? parsed
+                : [];
             } catch (error) {
               newInputs['AutoSwitchGroupRules'] = [];
             }
             break;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
newInputs['AutoSwitchGroupRules'] = JSON.parse(
item.value || '[]',
);
} catch (error) {
newInputs['AutoSwitchGroupRules'] = [];
}
try {
const parsed = JSON.parse(item.value || '[]');
newInputs['AutoSwitchGroupRules'] = Array.isArray(parsed)
? parsed
: [];
} catch (error) {
newInputs['AutoSwitchGroupRules'] = [];
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/settings/PaymentSetting.jsx` around lines 109 - 115, The
JSON.parse of item.value that assigns to newInputs['AutoSwitchGroupRules'] may
return non-array values; after parsing inside the try block (the code that sets
newInputs['AutoSwitchGroupRules']), validate the parsed result with
Array.isArray and only assign it if true, otherwise set
newInputs['AutoSwitchGroupRules'] = []; keep the existing catch to handle parse
errors but add the Array.isArray check for the parsed value to prevent non-array
objects from being stored.

Comment on lines 339 to +349
const handleFormChange = (values) => {
setInputs(values);
setInputs((prev) => {
const normalizedValues = normalizeAutoSwitchGroupInputs({
...prev,
...values,
});
if (compareObjects(prev, normalizedValues).length === 0) {
return prev;
}
return normalizedValues;
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Sync normalized values back into the form store.

normalizeAutoSwitchGroupInputs() only updates React state. Semi Form still keeps the old AutoSwitchGroupOnlyNewTopups value, so turning auto-switch off and then back on can resurrect a stale true and save the wrong mode.

🩹 One way to keep the form store and React state aligned
   const handleFormChange = (values) => {
-    setInputs((prev) => {
-      const normalizedValues = normalizeAutoSwitchGroupInputs({
-        ...prev,
-        ...values,
-      });
-      if (compareObjects(prev, normalizedValues).length === 0) {
-        return prev;
-      }
-      return normalizedValues;
-    });
+    const mergedValues = {
+      ...inputs,
+      ...values,
+    };
+    const normalizedValues = normalizeAutoSwitchGroupInputs(mergedValues);
+    if (compareObjects(mergedValues, normalizedValues).length !== 0) {
+      formApiRef.current?.setValues(normalizedValues);
+    }
+    setInputs((prev) =>
+      compareObjects(prev, normalizedValues).length === 0
+        ? prev
+        : normalizedValues,
+    );
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/Setting/Payment/SettingsPaymentGateway.jsx` around lines 339 -
349, handleFormChange currently normalizes inputs into React state only, leaving
the Semi Form store stale; update the form store with the same normalizedValues
when a change is accepted. Inside handleFormChange (after computing
normalizedValues and confirming compareObjects(prev, normalizedValues).length
!== 0), call the Semi Form API to set the form values (e.g., formApi.setValues
or formRef.current.setValues) with normalizedValues so the form store and React
state stay in sync, taking care to only call the form setter when values
actually changed to avoid infinite loops.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
model/option.go (2)

281-292: ⚠️ Potential issue | 🟠 Major

Mirror normalized base-group into OptionMap, not raw input.

UpdatePaymentSetting normalizes auto_switch_group_base_group, but OptionMap still stores the original raw value. This keeps API/UI-visible value inconsistent with runtime-enforced value.

Focused fix
 var paymentSettingErr error
-operation_setting.UpdatePaymentSetting(func(setting *operation_setting.PaymentSetting) {
+normalizedPaymentSetting := operation_setting.UpdatePaymentSetting(func(setting *operation_setting.PaymentSetting) {
   paymentSettingErr = config.UpdateConfigFromMap(setting, paymentSettingConfigMap)
 })
 if paymentSettingErr != nil {
   return paymentSettingErr
 }

 common.OptionMapRWMutex.Lock()
 defer common.OptionMapRWMutex.Unlock()
 for key, value := range optionValues {
+  if key == "payment_setting.auto_switch_group_base_group" {
+    common.OptionMap[key] = normalizedPaymentSetting.AutoSwitchGroupBaseGroup
+    continue
+  }
   common.OptionMap[key] = value
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model/option.go` around lines 281 - 292, Update OptionMap to store the
normalized auto_switch_group_base_group value instead of the raw input: inside
the UpdatePaymentSetting closure (the func(setting
*operation_setting.PaymentSetting) passed to
operation_setting.UpdatePaymentSetting), capture the normalized field (e.g.,
setting.AutoSwitchGroupBaseGroup) into a local variable; after the closure
completes and before writing optionValues into common.OptionMap (the loop using
common.OptionMapRWMutex), overwrite optionValues["auto_switch_group_base_group"]
with that captured normalized value so the value written into common.OptionMap
matches the runtime-normalized payment setting.

229-253: ⚠️ Potential issue | 🟠 Major

UpdateOptions can still fail after DB commit (partial success path).

The transaction commits before updateOptionMap / applyPaymentSettingOptionValues run. If either later step fails, the caller receives an error after persistence has already succeeded.

Suggested direction
- if err := DB.Transaction(func(tx *gorm.DB) error {
+ return DB.Transaction(func(tx *gorm.DB) error {
    for _, key := range keys {
      option := Option{Key: key}
      if err := tx.FirstOrCreate(&option, Option{Key: key}).Error; err != nil {
        return err
      }
      option.Value = optionValues[key]
      if err := tx.Save(&option).Error; err != nil {
        return err
      }
    }
-   return nil
- }); err != nil {
-   return err
- }
-
- for _, key := range keys {
-   if strings.HasPrefix(key, "payment_setting.") {
-     continue
-   }
-   if err := updateOptionMap(key, optionValues[key]); err != nil {
-     return err
-   }
- }
- return applyPaymentSettingOptionValues(paymentSettingOptionValues)
+   for _, key := range keys {
+     if strings.HasPrefix(key, "payment_setting.") {
+       continue
+     }
+     if err := updateOptionMap(key, optionValues[key]); err != nil {
+       return err
+     }
+   }
+   return applyPaymentSettingOptionValues(paymentSettingOptionValues)
+ })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model/option.go` around lines 229 - 253, The current flow commits DB changes
inside DB.Transaction then runs updateOptionMap and
applyPaymentSettingOptionValues, causing possible partial success; to fix,
perform the map update and payment-setting application inside the same
DB.Transaction closure so any failure returns an error and triggers rollback:
update the Transaction lambda that iterates keys (using Option, optionValues,
paymentSettingOptionValues, keys) to call updateOptionMap(key, ...) for
non-"payment_setting." keys and to call applyPaymentSettingOptionValues(...)
before returning nil; if updateOptionMap or applyPaymentSettingOptionValues are
non-DB operations, refactor them to return errors and make them safe to call
inside the transaction (or add TX-aware variants) so failures propagate and the
transaction can rollback.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@model/option.go`:
- Around line 225-227: The current validatePaymentSettingOptionValues is too
permissive because it calls config.UpdateConfigFromMap which skips unknown
keys/parse errors; change validatePaymentSettingOptionValues to perform strict
decoding into the exact expected config struct (e.g., marshal the option map to
JSON/YAML and unmarshal with a decoder that DisallowUnknownFields or use a
strict decoder/validator) and return an error on any unknown keys or parse
failures instead of silently accepting them; replace the permissive
UpdateConfigFromMap usage with this strict decode+validate flow and apply the
same strict approach to the other similar validators referenced in the review.

---

Duplicate comments:
In `@model/option.go`:
- Around line 281-292: Update OptionMap to store the normalized
auto_switch_group_base_group value instead of the raw input: inside the
UpdatePaymentSetting closure (the func(setting
*operation_setting.PaymentSetting) passed to
operation_setting.UpdatePaymentSetting), capture the normalized field (e.g.,
setting.AutoSwitchGroupBaseGroup) into a local variable; after the closure
completes and before writing optionValues into common.OptionMap (the loop using
common.OptionMapRWMutex), overwrite optionValues["auto_switch_group_base_group"]
with that captured normalized value so the value written into common.OptionMap
matches the runtime-normalized payment setting.
- Around line 229-253: The current flow commits DB changes inside DB.Transaction
then runs updateOptionMap and applyPaymentSettingOptionValues, causing possible
partial success; to fix, perform the map update and payment-setting application
inside the same DB.Transaction closure so any failure returns an error and
triggers rollback: update the Transaction lambda that iterates keys (using
Option, optionValues, paymentSettingOptionValues, keys) to call
updateOptionMap(key, ...) for non-"payment_setting." keys and to call
applyPaymentSettingOptionValues(...) before returning nil; if updateOptionMap or
applyPaymentSettingOptionValues are non-DB operations, refactor them to return
errors and make them safe to call inside the transaction (or add TX-aware
variants) so failures propagate and the transaction can rollback.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2cc63373-16a9-4340-b0f4-0a00a4dcd81c

📥 Commits

Reviewing files that changed from the base of the PR and between b1d7230 and 996cc40.

📒 Files selected for processing (2)
  • model/option.go
  • web/src/i18n/locales/fr.json
✅ Files skipped from review due to trivial changes (1)
  • web/src/i18n/locales/fr.json

Comment on lines +225 to +227
if err := validatePaymentSettingOptionValues(paymentSettingOptionValues); err != nil {
return err
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Current payment-setting “validation” is effectively non-strict.

validatePaymentSettingOptionValues relies on config.UpdateConfigFromMap, which is permissive (unknown keys / parse failures are skipped). Invalid values can pass validation and still be written to DB, causing persisted config to diverge from effective runtime config.

Suggested strict validation shape
+import "fmt"
...
func validatePaymentSettingOptionValues(optionValues map[string]string) error {
  if len(optionValues) == 0 {
    return nil
  }

  paymentSettingConfigMap := make(map[string]string, len(optionValues))
  for key, value := range optionValues {
    paymentSettingConfigMap[strings.TrimPrefix(key, "payment_setting.")] = value
  }

- paymentSetting := operation_setting.GetPaymentSetting()
- return config.UpdateConfigFromMap(&paymentSetting, paymentSettingConfigMap)
+ for key, value := range paymentSettingConfigMap {
+   v := strings.TrimSpace(value)
+   switch key {
+   case "auto_switch_group_enabled", "auto_switch_group_only_new_topups":
+     if _, err := strconv.ParseBool(v); err != nil {
+       return fmt.Errorf("invalid %s: %w", key, err)
+     }
+   case "auto_switch_group_enabled_from":
+     if _, err := strconv.ParseInt(v, 10, 64); err != nil {
+       return fmt.Errorf("invalid %s: %w", key, err)
+     }
+   case "auto_switch_group_base_group":
+     // normalized later; accept empty/whitespace.
+   case "auto_switch_group_rules":
+     var rules []operation_setting.PaymentAutoSwitchGroupRule
+     if err := common.UnmarshalJsonStr(v, &rules); err != nil {
+       return fmt.Errorf("invalid %s: %w", key, err)
+     }
+   default:
+     return fmt.Errorf("unsupported payment setting key: %s", key)
+   }
+ }
+ return nil
}

Also applies to: 256-268, 280-283

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model/option.go` around lines 225 - 227, The current
validatePaymentSettingOptionValues is too permissive because it calls
config.UpdateConfigFromMap which skips unknown keys/parse errors; change
validatePaymentSettingOptionValues to perform strict decoding into the exact
expected config struct (e.g., marshal the option map to JSON/YAML and unmarshal
with a decoder that DisallowUnknownFields or use a strict decoder/validator) and
return an error on any unknown keys or parse failures instead of silently
accepting them; replace the permissive UpdateConfigFromMap usage with this
strict decode+validate flow and apply the same strict approach to the other
similar validators referenced in the review.

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.

1 participant