Skip to content

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

Open
guoruqiang wants to merge 20 commits into
QuantumNous:mainfrom
guoruqiang:feature/topup-auto-switch-group
Open

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

Conversation

@guoruqiang

@guoruqiang guoruqiang commented Mar 23, 2026

Copy link
Copy Markdown
Contributor

变更说明

  • 为普通充值新增“按累计成功充值金额自动切换分组”能力。
  • 新增配置项 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。
  • 新增可视化支付设置 UI,用于开启功能、配置基础分组、配置阈值规则和仅统计新充值选项。

变更类型

  • Bug 修复
  • 新功能
  • 安全性与并发保护优化
  • 回归测试补充

安全性与兼容性

  • PaymentSetting 对外读取改为快照副本,避免暴露全局共享的 map / slice。
  • PaymentSetting 更新路径统一加锁并复制可变字段,避免配置读写竞争。
  • 普通充值完成后的自动切组逻辑在同一事务内锁定用户行,避免同一用户并发充值时少切一级。
  • Stripe 充值累计按 money 计算,非 Stripe 普通充值按 amount 计算,和归一化逻辑保持一致。
  • 支付配置更新会先完成校验和归一化,再同步写入数据库和运行时配置,避免 DB、UI、内存状态不一致。
  • 数据库操作保持 GORM 写法,兼容 SQLite、MySQL、PostgreSQL。

关联任务

  • 暂无

验证摘要

当前测试规则

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

已完成验证

  • 10 用户回归矩阵覆盖 default / user1 / vip / svip 初始分组、无历史充值、有历史充值、跨阈值累计、Stripe 累计、Creem + Epay 混合累计、重复补单幂等,以及 active subscription 用户再次普通充值。
  • 10 用户回归矩阵结果: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 失效后会回退到正确分组、链外分组不会被自动切组覆盖。

测试命令

  • go test ./setting/config ./setting/operation_setting ./model ./controller

额外验证

  • 使用本地真实 API 场景脚本完成普通充值自动切组验证。
  • 覆盖配置保存、规则校验、普通充值累计、仅统计新充值、基础分组约束、订阅优先级、手动分组保护、重复补单幂等和同一用户并发充值完成场景。

结论

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

Summary by CodeRabbit

  • New Features

    • Automatic user-group switching based on cumulative top-up USD thresholds, with options to enable, restrict to new top-ups with an effective cutoff, and configure a base group and threshold→group rules via settings UI and server endpoint.
    • Top-up flows now record completion time and trigger the auto-switch flow when applicable.
  • Bug Fixes / Validation

    • Server- and client-side rule validation, normalization, sorting and persistence to prevent invalid or duplicate rules.
  • Tests

    • Extensive tests for rule lifecycle, normalization, switching behavior, and subscription interactions.

@coderabbitai

coderabbitai Bot commented Mar 23, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a payment auto-switch-group feature: validates and persists auto-switch settings, computes USD-normalized successful top-ups, transactionally determines and applies user group switches based on thresholds, integrates into top-up completion and subscription expiry flows, and exposes a controller endpoint plus frontend settings UI.

Changes

Cohort / File(s) Summary
Auto-Switch Core & Topup
model/topup.go, model/topup_auto_switch_test.go, controller/topup.go
Implements USD normalization, per-user successful-topup USD summation, rule matching to select highest eligible group, transactional applyTopUpAutoSwitchGroup logic, exported ApplyTopUpAutoSwitchGroup, invoked from top-up completion flows; records top-up completion time; adds comprehensive unit tests.
Options & Persistence
model/option.go, model/option_payment_setting_test.go
Adds UpdateOptions bulk API that transactions multiple option upserts, normalizes payment_setting.* values before persistence, updates payment-setting via one batched mutation, and delegates UpdateOption to UpdateOptions; tests verify normalization is applied everywhere.
Controller Handler & Tests
controller/option.go, controller/option_payment_auto_switch_test.go
Adds UpdatePaymentAutoSwitchGroup handler and PaymentAutoSwitchGroupUpdateRequest, normalizes/validates base group and rules, derives enabled/only-new/enabled_from state, persists options via UpdateOptions; tests cover rule normalization and state-finalization error cases.
Subscription & Group Resolution
model/subscription.go
Adds tx-scoped helpers to resolve an effective fallback group (preferring active upgrade subscriptions), refactors downgrade/expiry flows to use resolver and persist group updates consistently.
Settings Model & Concurrency
setting/operation_setting/payment_setting.go, setting/operation_setting/payment_setting_test.go
Adds new AutoSwitch fields and rule type to PaymentSetting, makes GetPaymentSetting return deep-cloned snapshots under RWMutex, adds UpdatePaymentSetting callback-style updater and deep-copy helpers; tests validate snapshot semantics and base-group normalization.
Routing
router/api-router.go
Registers PUT /api/option/payment_auto_switch_group to the new controller handler under authenticated routes.
Frontend UI
web/src/components/settings/PaymentSetting.jsx, web/src/pages/Setting/Payment/SettingsPaymentAutoSwitchGroup.jsx
Adds UI tab and settings page for auto-switch: enable toggles, only-new-topups, base-group selector, editable threshold→group rules, unit conversion handling, client-side validation and save flow.
Config & Tests
setting/config/config.go, setting/config/config_test.go
Adds provider interfaces for config snapshot/update hooks used by config->map and apply logic; adds test ensuring snapshot path is used.
Tests & Minor Adjustments
controller/topup_waffo_pancake_test.go, model/topup_auto_switch_test.go
Adjusts tests to use UpdatePaymentSetting for safe test state mutation/restoration; adds extensive auto-switch tests including expiration behavior and threshold matching.

Sequence Diagrams

sequenceDiagram
    participant Client
    participant Controller as Controller\n(topup.go)
    participant Model as TopUpModel\n(model/topup.go)
    participant DB as Database
    participant OpSet as OperationSetting
    participant Cache as UserCache

    Client->>Controller: Top-up success webhook / complete
    Controller->>DB: Begin transaction
    Controller->>Model: Mark top-up success (record CompleteTime)
    Model->>DB: GetUserSuccessfulTopupTotalUSDTx (sum per-user successful topups)
    Model->>OpSet: Read PaymentSetting (AutoSwitch rules)
    Model->>Model: Match highest eligible rule → targetGroup
    Model->>DB: Check active subscription upgrade (getActiveSubscriptionUpgradeGroupTx)
    alt Active upgrade exists
        Model->>DB: Prefer upgrade group (no top-up switch)
    else
        Model->>DB: applyTopUpAutoSwitchGroupTx → update user.group
    end
    DB->>DB: Commit
    Controller->>Cache: UpdateUserGroupCache (if changed)
    Controller->>Client: Respond success
Loading
sequenceDiagram
    participant Cron
    participant SubModel as SubscriptionModel\n(model/subscription.go)
    participant TopUpModel as TopUpModel\n(model/topup.go)
    participant DB as Database
    participant OpSet as OperationSetting
    participant Cache as UserCache

    Cron->>SubModel: ExpireDueSubscriptions(cutoff)
    SubModel->>DB: Begin transaction
    SubModel->>DB: Find expired subscriptions (lock user)
    loop per expired subscription
        SubModel->>SubModel: Resolve effective fallback group
        SubModel->>TopUpModel: resolveUserEffectiveGroupTx (may call GetUserSuccessfulTopupTotalUSDTx)
        TopUpModel->>OpSet: Read AutoSwitch rules
        TopUpModel->>TopUpModel: Determine targetGroup via rules
        alt Active upgrade exists
            SubModel->>DB: Use active upgrade group (no switch)
        else if targetGroup valid and != current
            SubModel->>DB: updateUserGroupTx to targetGroup
        end
    end
    DB->>DB: Commit
    SubModel->>Cache: UpdateUserGroupCache for changed users
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

enhancement

Suggested reviewers

  • seefs001

Poem

🐰
I trim the rules and tally coins with care,
When dollars meet thresholds, I nudge you elsewhere.
From default burrow to VIP tree,
I hop your user group — swift and free!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.02% 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 accurately describes the main feature: adding automatic group switching rules for ordinary top-ups based on cumulative recharge amounts.
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.

✏️ 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.

@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.

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 thread model/option.go Outdated
Comment thread model/option.go Outdated
Comment thread model/topup.go Outdated
Comment thread model/topup.go Outdated
Comment thread model/topup.go Outdated
Comment thread setting/operation_setting/payment_setting.go Outdated
Comment thread web/src/components/settings/PaymentSetting.jsx Outdated
Comment thread web/src/i18n/locales/fr.json Outdated
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
Copy Markdown
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.

@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.

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 thread model/option.go Outdated
Comment on lines +225 to +227
if err := validatePaymentSettingOptionValues(paymentSettingOptionValues); err != nil {
return err
}

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.

⚠️ 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.

@ghost

This comment was marked as spam.

@SpeedGeeker

Copy link
Copy Markdown

希望能合并

@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.

♻️ Duplicate comments (2)
setting/operation_setting/payment_setting.go (1)

11-17: ⚠️ Potential issue | 🔴 Critical

Protect shared PaymentSetting from concurrent mutation.

Line 11-Line 17 introduce additional mutable fields (map/slice). Combined with global shared state, this amplifies race risk when settings are read while being updated. Please switch readers to a deep-copied snapshot (or guard access with RW locking) instead of exposing shared mutable internals.

Also applies to: 21-28

🤖 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 11 - 17, The
PaymentSetting struct exposes mutable internals (AmountOptions, AmountDiscount,
AutoSwitchGroupRules, etc.) that can race when shared globally; fix by either
protecting the shared instance with a RWMutex around all readers/writers of
PaymentSetting or by returning a deep-copied snapshot to callers: ensure any
accessor (e.g., GetPaymentSetting or similar) produces a full copy that
duplicates slices and maps (copy AmountOptions slice, deep-copy AmountDiscount
map, clone AutoSwitchGroupRules slice/entries) so callers never mutate shared
memory; apply the same treatment to the other mutable fields noted (lines
21–28).
model/topup.go (1)

232-281: ⚠️ Potential issue | 🔴 Critical

Serialize auto-switch recomputation per user to avoid threshold under-switch.

The cumulative-USD match + users.group update path is still vulnerable to concurrent completions for the same user. Two transactions can each compute totals without seeing the other uncommitted success and both persist a lower tier than the true post-commit cumulative tier. Lock the user row (or equivalent per-user mutex) for the full recompute-and-update section inside the transaction.

Also applies to: 367-368, 605-606, 683-684, 749-750, 815-816

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

In `@model/topup.go` around lines 232 - 281, The auto-switch path in
applyTopUpAutoSwitchGroupTx is vulnerable to concurrent transactions because
recompute (cumulative USD checks like getTopUpAutoSwitchTargetGroupTx /
getActiveSubscriptionUpgradeGroupTx) and the subsequent updateUserGroupTx are
not serialized; fix by acquiring a per-user row lock in the same tx before doing
the recompute-and-update sequence (use SELECT ... FOR UPDATE on the users row or
add a helper like getUserRowForUpdateTx / extend getUserGroupByIdTx with a
forUpdate flag), perform all reads that determine activeUpgradeGroup/targetGroup
while the lock is held, then call updateUserGroupTx and release the lock at
transaction end; apply the same locking pattern to the other
recompute-and-update locations that call
getTopUpAutoSwitchTargetGroupTx/updateUserGroupTx to prevent under-switch races.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@model/topup.go`:
- Around line 232-281: The auto-switch path in applyTopUpAutoSwitchGroupTx is
vulnerable to concurrent transactions because recompute (cumulative USD checks
like getTopUpAutoSwitchTargetGroupTx / getActiveSubscriptionUpgradeGroupTx) and
the subsequent updateUserGroupTx are not serialized; fix by acquiring a per-user
row lock in the same tx before doing the recompute-and-update sequence (use
SELECT ... FOR UPDATE on the users row or add a helper like
getUserRowForUpdateTx / extend getUserGroupByIdTx with a forUpdate flag),
perform all reads that determine activeUpgradeGroup/targetGroup while the lock
is held, then call updateUserGroupTx and release the lock at transaction end;
apply the same locking pattern to the other recompute-and-update locations that
call getTopUpAutoSwitchTargetGroupTx/updateUserGroupTx to prevent under-switch
races.

In `@setting/operation_setting/payment_setting.go`:
- Around line 11-17: The PaymentSetting struct exposes mutable internals
(AmountOptions, AmountDiscount, AutoSwitchGroupRules, etc.) that can race when
shared globally; fix by either protecting the shared instance with a RWMutex
around all readers/writers of PaymentSetting or by returning a deep-copied
snapshot to callers: ensure any accessor (e.g., GetPaymentSetting or similar)
produces a full copy that duplicates slices and maps (copy AmountOptions slice,
deep-copy AmountDiscount map, clone AutoSwitchGroupRules slice/entries) so
callers never mutate shared memory; apply the same treatment to the other
mutable fields noted (lines 21–28).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fcae2f99-6ec9-411b-9af8-04377813eb89

📥 Commits

Reviewing files that changed from the base of the PR and between 86dd731 and 4713226.

📒 Files selected for processing (11)
  • controller/option.go
  • controller/option_payment_auto_switch_test.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/pages/Setting/Payment/SettingsPaymentAutoSwitchGroup.jsx
✅ Files skipped from review due to trivial changes (1)
  • router/api-router.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • controller/topup.go
  • model/option.go

Return copied payment settings so mutable maps and slices are not shared across requests. Update payment settings through a locked path, and lock the user row before recomputing auto-switch or subscription-driven group changes.

@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.

Actionable comments posted: 1

♻️ Duplicate comments (3)
setting/operation_setting/payment_setting.go (1)

35-44: ⚠️ Potential issue | 🟠 Major

Reflection-based config readers still race against UpdatePaymentSetting.

init at Line 38 registers &paymentSetting directly, so any path that goes through config.GlobalConfig.Get("payment_setting") / ExportAllConfigs() (e.g., model.InitOptionMap reflecting the struct via ConfigToMap) reads the live struct without holding paymentSettingRWMutex. Concurrent calls to UpdatePaymentSetting mutate AmountOptions, AmountDiscount, and AutoSwitchGroupRules on the same instance, so reflection iteration of those slices/maps is a data race (and may panic on map iteration). Consider registering a getter/snapshot or routing reflective reads through the locked clone path.

🤖 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 35 - 44, The code
registers the live variable paymentSetting in init (via
config.GlobalConfig.Register("payment_setting", &paymentSetting)), allowing
reflection-based readers to access the mutable struct without acquiring
paymentSettingRWMutex and race with UpdatePaymentSetting; fix by changing the
registration to expose a safe snapshot getter instead of the live pointer —
i.e., have Register use a function or wrapper that calls
GetPaymentSetting()/clonePaymentSettingLocked() to return a copy (or register
the result of clonePaymentSettingLocked) so reflective reads always operate on
an immutable clone; update any related code paths that expect the raw pointer to
use the getter/register-snapshot approach and keep UpdatePaymentSetting and
clonePaymentSettingLocked unchanged.
model/option.go (2)

240-269: ⚠️ Potential issue | 🟠 Major

Atomicity gap: DB is committed before in-memory updates are applied/validated.

UpdateOptions commits the DB transaction at Line 254 and only then applies updateOptionMap / updatePaymentSettingOptionMap (Lines 256-268). If any of those fail (e.g., invalid auto_switch_group_rules JSON), the DB row has already been persisted while the in-memory common.OptionMap and operation_setting.paymentSetting are left untouched, so a subsequent process restart will load the bad value through loadOptionsFromDatabase and apply it without callers seeing a failure. Move the in-memory apply step inside the transaction (and roll back on error), or validate strictly before opening the transaction.

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

In `@model/option.go` around lines 240 - 269, UpdateOptions currently commits the
DB transaction before applying in-memory changes, causing an atomicity gap; move
the in-memory updates and validation into the same DB.Transaction callback so
any failure rolls back the DB. Specifically, inside the DB.Transaction(func(tx
*gorm.DB) error { ... }) where you FirstOrCreate/Save the Option records, build
paymentSettingOptionValues and call updateOptionMap(key, ...) and
updatePaymentSettingOptionMap(paymentSettingOptionValues) there (or validate
inputs beforehand) and return any error so the transaction is rolled back; keep
references to Option, keys, optionValues, updateOptionMap,
updatePaymentSettingOptionMap and DB.Transaction when making the change.

605-640: ⚠️ Potential issue | 🟠 Major

Raw auto_switch_group_base_group value still leaks into common.OptionMap.

UpdatePaymentSetting normalizes AutoSwitchGroupBaseGroup (e.g., ""/whitespace → "default"), but Line 637 writes back the original optionValues[key] (raw input) into common.OptionMap. The DB row in UpdateOptions (Line 246) is also written with the raw value. As a result, runtime operation_setting.GetPaymentSetting() returns the normalized value while the OptionMap/DB and the admin UI continue to show whatever was submitted, leading to drift between displayed and effective configuration. After UpdatePaymentSetting returns, mirror its AutoSwitchGroupBaseGroup (and any other normalized fields) into both OptionMap and the persisted row.

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

In `@model/option.go` around lines 605 - 640, The code writes raw optionValues
back into common.OptionMap and the DB, causing divergence from the normalized
fields updated by operation_setting.UpdatePaymentSetting; fix
updatePaymentSettingOptionMap by, after calling
operation_setting.UpdatePaymentSetting (or inside the callback), reading the
normalized PaymentSetting (e.g., via the setting pointer provided to the
callback or operation_setting.GetPaymentSetting()) and using those normalized
values (at minimum AutoSwitchGroupBaseGroup) to overwrite the corresponding
entries in common.OptionMap and the persisted row (the UpdateOptions path)
instead of the original raw optionValues; ensure you update both
common.OptionMap and the persisted UpdateOptions write to use the normalized
config keys/values produced by config.UpdateConfigFromMap/PaymentSetting
normalization so UI, DB, and runtime stay consistent.
🧹 Nitpick comments (2)
setting/operation_setting/payment_setting.go (1)

84-90: Duplicate normalization helper across packages.

normalizePaymentAutoSwitchGroupBaseGroup here is a verbatim duplicate of the same-named helper in model/topup.go (Lines 105-111). Consider exporting one canonical implementation (e.g., operation_setting.NormalizePaymentAutoSwitchGroupBaseGroup) and having model/topup.go call it, so future tweaks (e.g., default group name) don't drift between the two locations.

🤖 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 84 - 90, The
function normalizePaymentAutoSwitchGroupBaseGroup is duplicated; extract and
export a single canonical implementation (e.g.,
NormalizePaymentAutoSwitchGroupBaseGroup) in the operation_setting package and
remove the duplicate in model/topup.go; update callers in both packages to
import operation_setting and call
operation_setting.NormalizePaymentAutoSwitchGroupBaseGroup, and ensure imports
are adjusted and tests/build updated so the default trimming/empty->"default"
behavior remains identical.
model/topup.go (1)

105-111: Duplicate of the helper in setting/operation_setting/payment_setting.go.

This normalizePaymentAutoSwitchGroupBaseGroup is identical to the one defined in setting/operation_setting/payment_setting.go (Lines 84-90). Re-using a single exported implementation prevents future drift if the default group name or trim rules change.

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

In `@model/topup.go` around lines 105 - 111, Remove the duplicate
normalizePaymentAutoSwitchGroupBaseGroup in model/topup.go and delegate to the
single implementation in setting/operation_setting/payment_setting.go: import
the payment_setting package, delete the local
normalizePaymentAutoSwitchGroupBaseGroup definition, and replace all calls with
payment_setting.NormalizePaymentAutoSwitchGroupBaseGroup(...); if the helper in
payment_setting.go is not exported, rename it to an exported identifier
(NormalizePaymentAutoSwitchGroupBaseGroup) so model/topup.go can reuse it.
🤖 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/topup.go`:
- Around line 158-175: The current query filters with "amount > 0" which is
inconsistent with NormalizeTopUpValueUSD (which uses Money for Stripe); update
the query in the function that builds the top-up fetch (the query variable using
tx.Model(&TopUp{}) / Select(...).Where(...)) to align filtering with
normalization: either replace the "amount > 0" predicate with a predicate that
checks the monetary field used by NormalizeTopUpValueUSD (e.g., money > 0) or
add a composite condition that preserves non-Stripe logic but also includes rows
where payment_method = 'stripe' AND money > 0; ensure the filter references
TopUp.payment_method and TopUp.Money consistently so that NormalizeTopUpValueUSD
will not skip valid Stripe top-ups.

---

Duplicate comments:
In `@model/option.go`:
- Around line 240-269: UpdateOptions currently commits the DB transaction before
applying in-memory changes, causing an atomicity gap; move the in-memory updates
and validation into the same DB.Transaction callback so any failure rolls back
the DB. Specifically, inside the DB.Transaction(func(tx *gorm.DB) error { ... })
where you FirstOrCreate/Save the Option records, build
paymentSettingOptionValues and call updateOptionMap(key, ...) and
updatePaymentSettingOptionMap(paymentSettingOptionValues) there (or validate
inputs beforehand) and return any error so the transaction is rolled back; keep
references to Option, keys, optionValues, updateOptionMap,
updatePaymentSettingOptionMap and DB.Transaction when making the change.
- Around line 605-640: The code writes raw optionValues back into
common.OptionMap and the DB, causing divergence from the normalized fields
updated by operation_setting.UpdatePaymentSetting; fix
updatePaymentSettingOptionMap by, after calling
operation_setting.UpdatePaymentSetting (or inside the callback), reading the
normalized PaymentSetting (e.g., via the setting pointer provided to the
callback or operation_setting.GetPaymentSetting()) and using those normalized
values (at minimum AutoSwitchGroupBaseGroup) to overwrite the corresponding
entries in common.OptionMap and the persisted row (the UpdateOptions path)
instead of the original raw optionValues; ensure you update both
common.OptionMap and the persisted UpdateOptions write to use the normalized
config keys/values produced by config.UpdateConfigFromMap/PaymentSetting
normalization so UI, DB, and runtime stay consistent.

In `@setting/operation_setting/payment_setting.go`:
- Around line 35-44: The code registers the live variable paymentSetting in init
(via config.GlobalConfig.Register("payment_setting", &paymentSetting)), allowing
reflection-based readers to access the mutable struct without acquiring
paymentSettingRWMutex and race with UpdatePaymentSetting; fix by changing the
registration to expose a safe snapshot getter instead of the live pointer —
i.e., have Register use a function or wrapper that calls
GetPaymentSetting()/clonePaymentSettingLocked() to return a copy (or register
the result of clonePaymentSettingLocked) so reflective reads always operate on
an immutable clone; update any related code paths that expect the raw pointer to
use the getter/register-snapshot approach and keep UpdatePaymentSetting and
clonePaymentSettingLocked unchanged.

---

Nitpick comments:
In `@model/topup.go`:
- Around line 105-111: Remove the duplicate
normalizePaymentAutoSwitchGroupBaseGroup in model/topup.go and delegate to the
single implementation in setting/operation_setting/payment_setting.go: import
the payment_setting package, delete the local
normalizePaymentAutoSwitchGroupBaseGroup definition, and replace all calls with
payment_setting.NormalizePaymentAutoSwitchGroupBaseGroup(...); if the helper in
payment_setting.go is not exported, rename it to an exported identifier
(NormalizePaymentAutoSwitchGroupBaseGroup) so model/topup.go can reuse it.

In `@setting/operation_setting/payment_setting.go`:
- Around line 84-90: The function normalizePaymentAutoSwitchGroupBaseGroup is
duplicated; extract and export a single canonical implementation (e.g.,
NormalizePaymentAutoSwitchGroupBaseGroup) in the operation_setting package and
remove the duplicate in model/topup.go; update callers in both packages to
import operation_setting and call
operation_setting.NormalizePaymentAutoSwitchGroupBaseGroup, and ensure imports
are adjusted and tests/build updated so the default trimming/empty->"default"
behavior remains identical.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 253867c5-0241-413f-be19-3a0e3565f8e5

📥 Commits

Reviewing files that changed from the base of the PR and between 4713226 and e596b07.

📒 Files selected for processing (7)
  • controller/topup_waffo_pancake_test.go
  • model/option.go
  • model/subscription.go
  • model/topup.go
  • model/topup_auto_switch_test.go
  • setting/operation_setting/payment_setting.go
  • setting/operation_setting/payment_setting_test.go

Comment thread model/topup.go
Align Stripe top-up accumulation with money-based normalization, keep payment setting snapshots behind locked config access, and persist normalized payment option values consistently across DB, OptionMap, and runtime state.
@guoruqiang

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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)
model/subscription.go (1)

899-945: ⚠️ Potential issue | 🟠 Major

Don't increment expiredCount before the transaction succeeds.

Line 909 updates expiredCount before the later downgrade/group-switch work can fail. If this callback returns an error, the DB changes are rolled back but the outer counter stays incremented, so the function can report expired rows that never committed.

🛠️ Suggested fix
 	for userId := range userIds {
 		cacheGroup := ""
+		userExpiredCount := 0
 		err := DB.Transaction(func(tx *gorm.DB) error {
 			res := tx.Model(&UserSubscription{}).
 				Where("user_id = ? AND status = ? AND end_time > 0 AND end_time <= ?", userId, "active", now).
 				Updates(map[string]interface{}{
 					"status":     "expired",
 					"updated_at": common.GetTimestamp(),
 				})
 			if res.Error != nil {
 				return res.Error
 			}
-			expiredCount += int(res.RowsAffected)
+			userExpiredCount = int(res.RowsAffected)
@@
 			return nil
 		})
 		if err != nil {
 			return expiredCount, err
 		}
+		expiredCount += userExpiredCount
 		if cacheGroup != "" {
 			_ = UpdateUserGroupCache(userId, cacheGroup)
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model/subscription.go` around lines 899 - 945, The code increments the outer
expiredCount inside the DB.Transaction callback (using expiredCount +=
int(res.RowsAffected)), which can be rolled back if the transaction later
returns an error; change this by capturing the affected rows inside the
transaction (e.g. local var rowsAffectedFromTx) but do NOT modify the outer
expiredCount there, then after DB.Transaction returns successfully add
rowsAffectedFromTx to expiredCount; locate DB.Transaction block, the res :=
tx.Model(&UserSubscription{})... Updates call and the use of expiredCount, and
ensure functions like getUserGroupForUpdateTx, resolveUserEffectiveGroupTx and
updateUserGroupTx remain inside the tx but the increment of expiredCount happens
only on a successful transaction return.
♻️ Duplicate comments (3)
model/option.go (2)

233-255: ⚠️ Potential issue | 🟠 Major

Apply runtime state only after DB.Transaction returns successfully.

Lines 251-255 still call updateOptionMap / updatePaymentSettingOptionMap inside the transaction callback. Those mutate common.OptionMap and runtime globals immediately, so a later error or commit failure leaves memory on the new values while the DB rolls back.

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

In `@model/option.go` around lines 233 - 255, The transaction callback currently
calls updateOptionMap and updatePaymentSettingOptionMap which mutate runtime
globals while still inside DB.Transaction; instead, inside the transaction (in
the DB.Transaction anonymous func) only perform DB operations and build/return
the paymentSettingOptionValues map and a list/map of non-payment option
key->value pairs (using keys and normalizedOptionValues), then after
DB.Transaction returns successfully (err == nil) iterate over that collected
non-payment map and call updateOptionMap(key, value) and call
updatePaymentSettingOptionMap(paymentSettingOptionValues); reference
DB.Transaction, updateOptionMap, updatePaymentSettingOptionMap, keys,
normalizedOptionValues and ensure no global mutations happen inside the
transaction callback.

625-640: ⚠️ Potential issue | 🟠 Major

Invalid payment_setting.* writes are still accepted silently.

Both helpers rely on config.UpdateConfigFromMap, which skips unknown keys and parse failures instead of returning an error. That means UpdateOptions can return success for malformed payment-setting updates while ignoring the requested change, which is especially risky because UpdateOptions and UpdateOption are exported entry points.

Also applies to: 660-679

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

In `@model/option.go` around lines 625 - 640, UpdateOptions currently calls
config.UpdateConfigFromMap which silently skips unknown keys or parse failures;
fix by validating the update succeeded for every incoming key: after calling
config.UpdateConfigFromMap(paymentSetting, configMap) and converting back with
config.ConfigToMap(paymentSetting), compare the original configMap keys
(payment-setting keys passed into UpdateOptions/UpdateOption) against the keys
present in paymentSettingMap (use paymentSettingConfigKey and
paymentSettingOptionValues to map/normalize names) and return an explicit error
if any requested key was not applied or failed to parse; alternatively replace
the call with a strict updater that returns errors on unknown keys, ensuring
UpdateOptions/UpdateOption surface failures instead of silently ignoring them
(refer to operation_setting.GetPaymentSetting, config.UpdateConfigFromMap,
config.ConfigToMap, paymentSettingConfigKey, paymentSettingOptionValues,
normalizedOptionValues).
model/topup.go (1)

92-101: ⚠️ Potential issue | 🟠 Major

Use PaymentProvider as the discriminator here.

The provider-specific branches are keyed off PaymentMethod, but the rest of this file treats gateway identity as PaymentProvider. If a Stripe/Creem top-up stores a channel in payment_method, this code will normalize it with the wrong field or exclude it from the aggregate, so cumulative USD can be undercounted and the user never reaches the expected switch threshold.

Suggested fix
-	switch normalizeTopUpPaymentMethod(topUp.PaymentMethod) {
+	switch normalizeTopUpPaymentMethod(topUp.PaymentProvider) {
 	case PaymentMethodStripe:
 		return topUp.Money
 	case PaymentMethodCreem:
 		if common.QuotaPerUnit <= 0 {
 			return 0
@@
-	query := tx.Model(&TopUp{}).
-		Select("amount", "money", "payment_method").
+	query := tx.Model(&TopUp{}).
+		Select("amount", "money", "payment_provider").
 		Where(
-			"user_id = ? AND status = ? AND ((LOWER(payment_method) = ? AND money > 0) OR (LOWER(payment_method) <> ? AND amount > 0))",
+			"user_id = ? AND status = ? AND ((LOWER(payment_provider) = ? AND money > 0) OR (LOWER(payment_provider) <> ? AND amount > 0))",
 			userId,
 			common.TopUpStatusSuccess,
 			PaymentMethodStripe,
 			PaymentMethodStripe,
 		)

Also applies to: 150-158

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

In `@model/topup.go` around lines 92 - 101, The switch is using
normalizeTopUpPaymentMethod(topUp.PaymentMethod) but the rest of the codebase
uses PaymentProvider as the gateway discriminator; change the switch to use the
provider field and its normalizer (e.g.,
normalizeTopUpPaymentProvider(topUp.PaymentProvider) or
normalizeTopUpPaymentProvider(topUp.PaymentProvider.String())) and switch on
PaymentProvider constants (PaymentProviderStripe, PaymentProviderCreem) instead
of PaymentMethod; update the analogous switch at the other occurrence (around
the 150-158 block) so both places consistently use topUp.PaymentProvider and the
provider normalizer to compute the returned amounts.
🧹 Nitpick comments (1)
model/topup_auto_switch_test.go (1)

111-115: Run these assertions through a real transaction.

These cases pass the root DB handle into applyTopUpAutoSwitchGroupTx, so they never exercise the same transaction/locking path used in production. That makes the tests too weak to catch MySQL/PostgreSQL regressions around the FOR UPDATE/locking behavior. Wrap the call in DB.Transaction(...) or call ApplyTopUpAutoSwitchGroup instead.

Also applies to: 124-128, 147-151

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

In `@model/topup_auto_switch_test.go` around lines 111 - 115, The test is calling
applyTopUpAutoSwitchGroupTx with the root DB handle so it bypasses the
transaction/locking path; change the test to execute the logic inside a real
transaction by either wrapping the call in DB.Transaction(...) (invoking
applyTopUpAutoSwitchGroupTx inside the transaction callback) or by calling the
higher-level ApplyTopUpAutoSwitchGroup which opens a transaction itself; update
the assertions around switchedGroup/getUserGroupForAutoSwitchTest to run after
the transaction completes and apply the same change to the other occurrences
mentioned (the blocks around the calls at the other lines).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@model/subscription.go`:
- Around line 899-945: The code increments the outer expiredCount inside the
DB.Transaction callback (using expiredCount += int(res.RowsAffected)), which can
be rolled back if the transaction later returns an error; change this by
capturing the affected rows inside the transaction (e.g. local var
rowsAffectedFromTx) but do NOT modify the outer expiredCount there, then after
DB.Transaction returns successfully add rowsAffectedFromTx to expiredCount;
locate DB.Transaction block, the res := tx.Model(&UserSubscription{})... Updates
call and the use of expiredCount, and ensure functions like
getUserGroupForUpdateTx, resolveUserEffectiveGroupTx and updateUserGroupTx
remain inside the tx but the increment of expiredCount happens only on a
successful transaction return.

---

Duplicate comments:
In `@model/option.go`:
- Around line 233-255: The transaction callback currently calls updateOptionMap
and updatePaymentSettingOptionMap which mutate runtime globals while still
inside DB.Transaction; instead, inside the transaction (in the DB.Transaction
anonymous func) only perform DB operations and build/return the
paymentSettingOptionValues map and a list/map of non-payment option key->value
pairs (using keys and normalizedOptionValues), then after DB.Transaction returns
successfully (err == nil) iterate over that collected non-payment map and call
updateOptionMap(key, value) and call
updatePaymentSettingOptionMap(paymentSettingOptionValues); reference
DB.Transaction, updateOptionMap, updatePaymentSettingOptionMap, keys,
normalizedOptionValues and ensure no global mutations happen inside the
transaction callback.
- Around line 625-640: UpdateOptions currently calls config.UpdateConfigFromMap
which silently skips unknown keys or parse failures; fix by validating the
update succeeded for every incoming key: after calling
config.UpdateConfigFromMap(paymentSetting, configMap) and converting back with
config.ConfigToMap(paymentSetting), compare the original configMap keys
(payment-setting keys passed into UpdateOptions/UpdateOption) against the keys
present in paymentSettingMap (use paymentSettingConfigKey and
paymentSettingOptionValues to map/normalize names) and return an explicit error
if any requested key was not applied or failed to parse; alternatively replace
the call with a strict updater that returns errors on unknown keys, ensuring
UpdateOptions/UpdateOption surface failures instead of silently ignoring them
(refer to operation_setting.GetPaymentSetting, config.UpdateConfigFromMap,
config.ConfigToMap, paymentSettingConfigKey, paymentSettingOptionValues,
normalizedOptionValues).

In `@model/topup.go`:
- Around line 92-101: The switch is using
normalizeTopUpPaymentMethod(topUp.PaymentMethod) but the rest of the codebase
uses PaymentProvider as the gateway discriminator; change the switch to use the
provider field and its normalizer (e.g.,
normalizeTopUpPaymentProvider(topUp.PaymentProvider) or
normalizeTopUpPaymentProvider(topUp.PaymentProvider.String())) and switch on
PaymentProvider constants (PaymentProviderStripe, PaymentProviderCreem) instead
of PaymentMethod; update the analogous switch at the other occurrence (around
the 150-158 block) so both places consistently use topUp.PaymentProvider and the
provider normalizer to compute the returned amounts.

---

Nitpick comments:
In `@model/topup_auto_switch_test.go`:
- Around line 111-115: The test is calling applyTopUpAutoSwitchGroupTx with the
root DB handle so it bypasses the transaction/locking path; change the test to
execute the logic inside a real transaction by either wrapping the call in
DB.Transaction(...) (invoking applyTopUpAutoSwitchGroupTx inside the transaction
callback) or by calling the higher-level ApplyTopUpAutoSwitchGroup which opens a
transaction itself; update the assertions around
switchedGroup/getUserGroupForAutoSwitchTest to run after the transaction
completes and apply the same change to the other occurrences mentioned (the
blocks around the calls at the other lines).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1caf8d4d-d0f8-4dc9-8ca4-d8fa905d7a54

📥 Commits

Reviewing files that changed from the base of the PR and between 4713226 and f5e1eae.

📒 Files selected for processing (11)
  • controller/topup_waffo_pancake_test.go
  • model/option.go
  • model/option_payment_setting_test.go
  • model/subscription.go
  • model/topup.go
  • model/topup_auto_switch_test.go
  • setting/config/config.go
  • setting/config/config_test.go
  • setting/operation_setting/payment_setting.go
  • setting/operation_setting/payment_setting_test.go
  • web/src/components/settings/PaymentSetting.jsx

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.

3 participants