Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ac39735
feat: add topup auto switch group rules
guoruqiang Mar 22, 2026
7ef16fa
refactor: simplify topup auto-switch group checks
guoruqiang Mar 22, 2026
164680a
revert: drop unnecessary topup auto-switch refactors
guoruqiang Mar 22, 2026
cbaa7a4
style: polish topup auto-switch group hints
guoruqiang Mar 22, 2026
c8b50e7
Merge branch 'QuantumNous:main' into feature/topup-auto-switch-group
guoruqiang Mar 22, 2026
db62ff8
feat: support counting only new topups for auto switch group
guoruqiang Mar 22, 2026
dc63554
fix: scope topup auto switch group to configured base group
guoruqiang Mar 22, 2026
5ab3df2
refactor: streamline topup auto-switch group settings
guoruqiang Mar 23, 2026
0227bed
Merge branch 'QuantumNous:main' into feature/topup-auto-switch-group
guoruqiang Mar 23, 2026
0a0fb66
Merge remote-tracking branch 'upstream/main' into feature/topup-auto-…
guoruqiang Mar 23, 2026
b1d7230
Merge branch 'feature/topup-auto-switch-group' of https://github.com/…
guoruqiang Mar 23, 2026
996cc40
refactor: validate payment setting updates before persist
guoruqiang Mar 24, 2026
86dd731
Merge branch 'QuantumNous:main' into feature/topup-auto-switch-group
guoruqiang Apr 1, 2026
bab2518
Merge branch 'QuantumNous:main' into feature/topup-auto-switch-group
vynaris Apr 2, 2026
4713226
chore: sync topup auto switch with upstream
guoruqiang Apr 27, 2026
e596b07
fix: guard top-up auto switch concurrency
guoruqiang Apr 27, 2026
f3012c9
fix: tighten top-up auto switch settings
guoruqiang Apr 27, 2026
f5e1eae
fix: validate auto switch group rules type
guoruqiang Apr 28, 2026
536d456
Merge upstream/main into feature/topup-auto-switch-group
guoruqiang May 19, 2026
e072b13
Merge upstream/main into feature/topup-auto-switch-group
guoruqiang Jul 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions controller/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package controller

import (
"fmt"
"math"
"net/http"
"sort"
"strconv"
"strings"

"github.com/QuantumNous/new-api/common"
Expand Down Expand Up @@ -112,6 +115,164 @@ type OptionUpdateRequest struct {
Value any `json:"value"`
}

type PaymentAutoSwitchGroupUpdateRequest struct {
Enabled bool `json:"enabled"`
OnlyNewTopups bool `json:"only_new_topups"`
BaseGroup string `json:"base_group"`
Rules []operation_setting.PaymentAutoSwitchGroupRule `json:"rules"`
}

type normalizedPaymentAutoSwitchGroupRules struct {
Rules []operation_setting.PaymentAutoSwitchGroupRule
JSON string
}

const paymentAutoSwitchGroupRulesRequiredMessage = "启用充值后自动切换分组前,请至少配置一条合法规则"

func normalizePaymentAutoSwitchGroupBaseGroup(baseGroup string) string {
trimmed := strings.TrimSpace(baseGroup)
if trimmed == "" {
return "default"
}
return trimmed
}

func isValidPaymentAutoSwitchGroup(group string) bool {
group = strings.TrimSpace(group)
if group == "" {
return false
}
_, ok := ratio_setting.GetGroupRatioCopy()[group]
return ok
}

func marshalPaymentAutoSwitchGroupRules(rules []operation_setting.PaymentAutoSwitchGroupRule) (string, error) {
if len(rules) == 0 {
rules = []operation_setting.PaymentAutoSwitchGroupRule{}
}
jsonBytes, err := common.Marshal(rules)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}

func normalizePaymentAutoSwitchGroupRulesList(rules []operation_setting.PaymentAutoSwitchGroupRule) (*normalizedPaymentAutoSwitchGroupRules, error) {
normalizedRules := make([]operation_setting.PaymentAutoSwitchGroupRule, 0, len(rules))
seenThresholds := make(map[string]struct{}, len(rules))
for idx, rule := range rules {
group := strings.TrimSpace(rule.Group)
if math.IsNaN(rule.ThresholdUSD) || math.IsInf(rule.ThresholdUSD, 0) || rule.ThresholdUSD <= 0 {
return nil, fmt.Errorf("第 %d 条充值自动切换分组规则的阈值必须大于 0", idx+1)
}
if !isValidPaymentAutoSwitchGroup(group) {
return nil, fmt.Errorf("第 %d 条充值自动切换分组规则的分组不存在", idx+1)
}
thresholdKey := strconv.FormatFloat(rule.ThresholdUSD, 'f', -1, 64)
if _, exists := seenThresholds[thresholdKey]; exists {
return nil, fmt.Errorf("充值自动切换分组规则存在重复阈值: %s USD", thresholdKey)
}
seenThresholds[thresholdKey] = struct{}{}
normalizedRules = append(normalizedRules, operation_setting.PaymentAutoSwitchGroupRule{
ThresholdUSD: rule.ThresholdUSD,
Group: group,
})
}

sort.Slice(normalizedRules, func(i, j int) bool {
return normalizedRules[i].ThresholdUSD < normalizedRules[j].ThresholdUSD
})

normalizedJSON, err := marshalPaymentAutoSwitchGroupRules(normalizedRules)
if err != nil {
return nil, err
}
return &normalizedPaymentAutoSwitchGroupRules{Rules: normalizedRules, JSON: normalizedJSON}, nil
}

func finalizeRequestedPaymentAutoSwitchGroupState(currentState *operation_setting.PaymentSetting, enabled bool, onlyNewTopups bool) (bool, bool, int64) {
enabledFrom := int64(0)
currentEnabled := false
currentOnlyNewTopups := false
currentEnabledFrom := int64(0)
if currentState != nil {
currentEnabled = currentState.AutoSwitchGroupEnabled
currentOnlyNewTopups = currentState.AutoSwitchGroupOnlyNewTopups
currentEnabledFrom = currentState.AutoSwitchGroupEnabledFrom
}

onlyNewTopups = enabled && onlyNewTopups
if enabled && onlyNewTopups {
enabledFrom = currentEnabledFrom
if !currentEnabled || !currentOnlyNewTopups {
enabledFrom = common.GetTimestamp() + 1
}
}
return enabled, onlyNewTopups, enabledFrom
}

func buildPaymentAutoSwitchGroupOptionValues(normalizedBaseGroup string, normalizedRules *normalizedPaymentAutoSwitchGroupRules, enabled bool, onlyNewTopups bool, enabledFrom int64) map[string]string {
return map[string]string{
"payment_setting.auto_switch_group_base_group": normalizedBaseGroup,
"payment_setting.auto_switch_group_rules": normalizedRules.JSON,
"payment_setting.auto_switch_group_enabled": strconv.FormatBool(enabled),
"payment_setting.auto_switch_group_only_new_topups": strconv.FormatBool(onlyNewTopups),
"payment_setting.auto_switch_group_enabled_from": strconv.FormatInt(enabledFrom, 10),
}
}

func UpdatePaymentAutoSwitchGroup(c *gin.Context) {
var request PaymentAutoSwitchGroupUpdateRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "无效的参数",
})
return
}

normalizedBaseGroup := normalizePaymentAutoSwitchGroupBaseGroup(request.BaseGroup)
if !isValidPaymentAutoSwitchGroup(normalizedBaseGroup) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "充值自动切换分组的基础分组不存在",
})
return
}

normalizedRules, err := normalizePaymentAutoSwitchGroupRulesList(request.Rules)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
if request.Enabled && len(normalizedRules.Rules) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": paymentAutoSwitchGroupRulesRequiredMessage,
})
return
}

enabled, onlyNewTopups, enabledFrom := finalizeRequestedPaymentAutoSwitchGroupState(
operation_setting.GetPaymentSetting(),
request.Enabled,
request.OnlyNewTopups,
)
optionValues := buildPaymentAutoSwitchGroupOptionValues(normalizedBaseGroup, normalizedRules, enabled, onlyNewTopups, enabledFrom)
if err := model.UpdateOptions(optionValues); err != nil {
common.ApiError(c, err)
return
}

c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
}

func UpdateOption(c *gin.Context) {
var option OptionUpdateRequest
err := common.DecodeJson(c.Request.Body, &option)
Expand Down
66 changes: 66 additions & 0 deletions controller/option_payment_auto_switch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package controller

import (
"testing"

"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNormalizePaymentAutoSwitchGroupRulesList_SortsAndMarshalsRules(t *testing.T) {
normalized, err := normalizePaymentAutoSwitchGroupRulesList([]operation_setting.PaymentAutoSwitchGroupRule{
{ThresholdUSD: 50, Group: "svip"},
{ThresholdUSD: 10, Group: " vip "},
})
require.NoError(t, err)

assert.Equal(t, []operation_setting.PaymentAutoSwitchGroupRule{
{ThresholdUSD: 10, Group: "vip"},
{ThresholdUSD: 50, Group: "svip"},
}, normalized.Rules)
assert.Equal(t, `[{"threshold_usd":10,"group":"vip"},{"threshold_usd":50,"group":"svip"}]`, normalized.JSON)
}

func TestNormalizePaymentAutoSwitchGroupRulesList_RejectsInvalidRules(t *testing.T) {
_, err := normalizePaymentAutoSwitchGroupRulesList([]operation_setting.PaymentAutoSwitchGroupRule{
{ThresholdUSD: 0, Group: "vip"},
})
require.Error(t, err)

_, err = normalizePaymentAutoSwitchGroupRulesList([]operation_setting.PaymentAutoSwitchGroupRule{
{ThresholdUSD: 10, Group: "missing"},
})
require.Error(t, err)

_, err = normalizePaymentAutoSwitchGroupRulesList([]operation_setting.PaymentAutoSwitchGroupRule{
{ThresholdUSD: 10, Group: "vip"},
{ThresholdUSD: 10, Group: "svip"},
})
require.Error(t, err)
}

func TestFinalizeRequestedPaymentAutoSwitchGroupState_OnlyNewCutoffLifecycle(t *testing.T) {
current := &operation_setting.PaymentSetting{
AutoSwitchGroupEnabled: false,
AutoSwitchGroupOnlyNewTopups: false,
AutoSwitchGroupEnabledFrom: 0,
}

enabled, onlyNewTopups, enabledFrom := finalizeRequestedPaymentAutoSwitchGroupState(current, true, true)
assert.True(t, enabled)
assert.True(t, onlyNewTopups)
assert.Greater(t, enabledFrom, int64(0))

current.AutoSwitchGroupEnabled = true
current.AutoSwitchGroupOnlyNewTopups = true
current.AutoSwitchGroupEnabledFrom = enabledFrom
_, onlyNewTopups, retainedEnabledFrom := finalizeRequestedPaymentAutoSwitchGroupState(current, true, true)
assert.True(t, onlyNewTopups)
assert.Equal(t, enabledFrom, retainedEnabledFrom)

enabled, onlyNewTopups, enabledFrom = finalizeRequestedPaymentAutoSwitchGroupState(current, false, true)
assert.False(t, enabled)
assert.False(t, onlyNewTopups)
assert.Zero(t, enabledFrom)
}
7 changes: 7 additions & 0 deletions controller/topup.go
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ func EpayNotify(c *gin.Context) {
topUp.PaymentMethod = verifyInfo.Type
}
topUp.Status = common.TopUpStatusSuccess
topUp.CompleteTime = common.GetTimestamp()
err := topUp.Update()
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新充值订单失败 trade_no=%s user_id=%d client_ip=%s error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), err.Error(), common.GetJsonString(topUp)))
Expand All @@ -394,6 +395,12 @@ func EpayNotify(c *gin.Context) {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新用户额度失败 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, err.Error(), common.GetJsonString(topUp)))
return
}
if switchedGroup, err := model.ApplyTopUpAutoSwitchGroup(topUp.UserId); err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 自动切换分组失败 trade_no=%s user_id=%d client_ip=%s error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), err.Error(), common.GetJsonString(topUp)))
return
} else if switchedGroup != "" {
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 自动切换分组成功 trade_no=%s user_id=%d target_group=%s client_ip=%s", topUp.TradeNo, topUp.UserId, switchedGroup, c.ClientIP()))
}
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值成功 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d money=%.2f topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, topUp.Money, common.GetJsonString(topUp)))
model.RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money), c.ClientIP(), topUp.PaymentMethod, "epay")
}
Expand Down
21 changes: 13 additions & 8 deletions controller/topup_waffo_pancake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,25 +30,30 @@ func TestFormatWaffoPancakeAmount_UsesDisplayPriceString(t *testing.T) {
func TestGetWaffoPancakePayMoney(t *testing.T) {
originalUnitPrice := setting.WaffoPancakeUnitPrice
originalQuotaDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType
originalDiscounts := make(map[int]float64, len(operation_setting.GetPaymentSetting().AmountDiscount))
for k, v := range operation_setting.GetPaymentSetting().AmountDiscount {
paymentSetting := operation_setting.GetPaymentSetting()
originalDiscounts := make(map[int]float64, len(paymentSetting.AmountDiscount))
for k, v := range paymentSetting.AmountDiscount {
originalDiscounts[k] = v
}
originalTopupGroupRatio := common.TopupGroupRatio2JSONString()

t.Cleanup(func() {
setting.WaffoPancakeUnitPrice = originalUnitPrice
operation_setting.GetGeneralSetting().QuotaDisplayType = originalQuotaDisplayType
operation_setting.GetPaymentSetting().AmountDiscount = originalDiscounts
operation_setting.UpdatePaymentSetting(func(setting *operation_setting.PaymentSetting) {
setting.AmountDiscount = originalDiscounts
})
require.NoError(t, common.UpdateTopupGroupRatioByJSONString(originalTopupGroupRatio))
})

setting.WaffoPancakeUnitPrice = 2.5
operation_setting.GetPaymentSetting().AmountDiscount = map[int]float64{
10: 0.8,
int(common.QuotaPerUnit * 3): 0.5,
20: 0,
}
operation_setting.UpdatePaymentSetting(func(setting *operation_setting.PaymentSetting) {
setting.AmountDiscount = map[int]float64{
10: 0.8,
int(common.QuotaPerUnit * 3): 0.5,
20: 0,
}
})
require.NoError(t, common.UpdateTopupGroupRatioByJSONString(`{"default":1,"vip":1.2}`))

testCases := []struct {
Expand Down
Loading