From 23f9ed4ccdeedd6406589de12d4521a58496af51 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Mon, 1 Jun 2026 16:22:39 +0800 Subject: [PATCH] fix(redemption): block synced users from redeeming Reject redemption requests from synced users, surface a localized API message, and cover both model- and controller-level paths with tests. Co-Authored-By: Codex --- controller/user.go | 4 ++ controller/user_topup_test.go | 106 ++++++++++++++++++++++++++++++++++ i18n/keys.go | 1 + i18n/locales/en.yaml | 1 + i18n/locales/zh-CN.yaml | 1 + i18n/locales/zh-TW.yaml | 1 + model/redemption.go | 12 ++++ model/redemption_test.go | 49 ++++++++++++++-- 8 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 controller/user_topup_test.go diff --git a/controller/user.go b/controller/user.go index 357b63b..c82118a 100644 --- a/controller/user.go +++ b/controller/user.go @@ -1029,6 +1029,10 @@ func TopUp(c *gin.Context) { } quota, err := model.Redeem(req.Key, id) if err != nil { + if errors.Is(err, model.ErrSyncedUserRedeemDenied) { + common.ApiErrorI18n(c, i18n.MsgRedemptionSyncedUserDenied) + return + } if errors.Is(err, model.ErrRedeemFailed) { common.ApiErrorI18n(c, i18n.MsgRedeemFailed) return diff --git a/controller/user_topup_test.go b/controller/user_topup_test.go new file mode 100644 index 0000000..696a0e7 --- /dev/null +++ b/controller/user_topup_test.go @@ -0,0 +1,106 @@ +package controller + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/i18n" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupUserTopUpTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + + origDB := model.DB + origLogDB := model.LOG_DB + origUsingSQLite := common.UsingSQLite + origRedisEnabled := common.RedisEnabled + + model.DB = db + model.LOG_DB = db + common.UsingSQLite = true + common.RedisEnabled = false + + require.NoError(t, db.AutoMigrate(&model.User{}, &model.Redemption{}, &model.Log{})) + + t.Cleanup(func() { + model.DB = origDB + model.LOG_DB = origLogDB + common.UsingSQLite = origUsingSQLite + common.RedisEnabled = origRedisEnabled + require.NoError(t, sqlDB.Close()) + }) + + return db +} + +func setupUserTopUpRouter(userID int) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/api/user/topup", func(c *gin.Context) { + c.Set("id", userID) + c.Next() + }, TopUp) + return r +} + +func TestTopUp_SyncedUserDenied(t *testing.T) { + db := setupUserTopUpTestDB(t) + i18n.Init() + + user := &model.User{ + Id: 100, + Username: "synced-topup-test", + Password: "hashed_password", + Quota: 100000, + Source: common.UserSourceSynced, + } + require.NoError(t, db.Create(user).Error) + + redemption := &model.Redemption{ + Id: 1, + UserId: 1, + Key: "synced-topup-key", + Status: common.RedemptionCodeStatusEnabled, + Quota: 50000, + CreatedTime: common.GetTimestamp(), + } + require.NoError(t, db.Create(redemption).Error) + + router := setupUserTopUpRouter(100) + w := httptest.NewRecorder() + reqBody, err := common.Marshal(map[string]string{"key": "synced-topup-key"}) + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodPost, "/api/user/topup", bytes.NewReader(reqBody)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept-Language", "zh-CN") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + require.NoError(t, common.Unmarshal(w.Body.Bytes(), &resp)) + assert.False(t, resp["success"].(bool)) + assert.Equal(t, "同步用户不允许使用兑换码", resp["message"]) + + var updatedUser model.User + require.NoError(t, db.First(&updatedUser, 100).Error) + assert.Equal(t, 100000, updatedUser.Quota) +} diff --git a/i18n/keys.go b/i18n/keys.go index 187a451..74506fc 100644 --- a/i18n/keys.go +++ b/i18n/keys.go @@ -54,6 +54,7 @@ const ( MsgRedemptionUsed = "redemption.used" MsgRedemptionExpired = "redemption.expired" MsgRedemptionFailed = "redemption.failed" + MsgRedemptionSyncedUserDenied = "redemption.synced_user_denied" MsgRedemptionNotProvided = "redemption.not_provided" MsgRedemptionExpireTimeInvalid = "redemption.expire_time_invalid" ) diff --git a/i18n/locales/en.yaml b/i18n/locales/en.yaml index b9609f2..ac708f9 100644 --- a/i18n/locales/en.yaml +++ b/i18n/locales/en.yaml @@ -46,6 +46,7 @@ redemption.invalid: "Invalid redemption code" redemption.used: "This redemption code has been used" redemption.expired: "This redemption code has expired" redemption.failed: "Redemption failed, please try again later" +redemption.synced_user_denied: "Synced users cannot use redemption codes" redemption.not_provided: "Redemption code not provided" redemption.expire_time_invalid: "Expiration time cannot be earlier than current time" diff --git a/i18n/locales/zh-CN.yaml b/i18n/locales/zh-CN.yaml index 73711f0..28fb489 100644 --- a/i18n/locales/zh-CN.yaml +++ b/i18n/locales/zh-CN.yaml @@ -47,6 +47,7 @@ redemption.invalid: "无效的兑换码" redemption.used: "该兑换码已被使用" redemption.expired: "该兑换码已过期" redemption.failed: "兑换失败,请稍后重试" +redemption.synced_user_denied: "同步用户不允许使用兑换码" redemption.not_provided: "未提供兑换码" redemption.expire_time_invalid: "过期时间不能早于当前时间" diff --git a/i18n/locales/zh-TW.yaml b/i18n/locales/zh-TW.yaml index c83f2f5..fad3f0f 100644 --- a/i18n/locales/zh-TW.yaml +++ b/i18n/locales/zh-TW.yaml @@ -47,6 +47,7 @@ redemption.invalid: "無效的兌換碼" redemption.used: "該兌換碼已被使用" redemption.expired: "該兌換碼已過期" redemption.failed: "兌換失敗,請稍後重試" +redemption.synced_user_denied: "同步使用者不允許使用兌換碼" redemption.not_provided: "未提供兌換碼" redemption.expire_time_invalid: "過期時間不能早於當前時間" diff --git a/model/redemption.go b/model/redemption.go index b4988f4..689a1ea 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -14,6 +14,9 @@ import ( // ErrRedeemFailed is returned when redemption fails due to database error var ErrRedeemFailed = errors.New("redeem.failed") +// ErrSyncedUserRedeemDenied is returned when a synced user tries to redeem a code. +var ErrSyncedUserRedeemDenied = errors.New("redemption.synced_user_denied") + type Redemption struct { Id int `json:"id"` UserId int `json:"user_id"` @@ -123,6 +126,15 @@ func Redeem(key string, userId int) (quota int, err error) { if userId == 0 { return 0, errors.New("无效的 user id") } + user := &User{} + err = DB.Select("id", "source").First(user, "id = ?", userId).Error + if err != nil { + return 0, err + } + if user.IsSyncedUser() { + return 0, ErrSyncedUserRedeemDenied + } + redemption := &Redemption{} keyCol := "`key`" diff --git a/model/redemption_test.go b/model/redemption_test.go index 9d88962..f78a83e 100644 --- a/model/redemption_test.go +++ b/model/redemption_test.go @@ -241,15 +241,54 @@ func TestRedeem_SyncedUser(t *testing.T) { // 执行兑换 quota, err := Redeem("synced-redeem-key", 100) - require.NoError(t, err) - assert.Equal(t, 50000, quota) + require.ErrorIs(t, err, ErrSyncedUserRedeemDenied) + assert.Equal(t, 0, quota) - // 验证回调被触发(Master 节点需要同步到 Slave) - assert.True(t, callbackCalled) + // 同步用户不能兑换,回调不应触发 + assert.False(t, callbackCalled) - // 验证用户额度已更新 + // 验证用户额度和同步额度均未更新 var updatedUser User require.NoError(t, db.First(&updatedUser, 100).Error) + assert.Equal(t, 100000, updatedUser.Quota) + assert.Equal(t, 100000, updatedUser.SyncedQuota) + + // 验证兑换码仍保持可用,不能被同步用户消耗 + var updatedRedemption Redemption + require.NoError(t, db.First(&updatedRedemption, 1).Error) + assert.Equal(t, common.RedemptionCodeStatusEnabled, updatedRedemption.Status) + assert.Equal(t, 0, updatedRedemption.UsedUserId) + assert.Equal(t, int64(0), updatedRedemption.RedeemedTime) +} + +func TestRedeem_LocalUserStillWorks(t *testing.T) { + db := setupRedemptionDB(t) + + user := User{ + Id: 101, + Username: "local_redeem_test", + Password: "hashed_password", + Quota: 100000, + Source: common.UserSourceLocal, + } + require.NoError(t, db.Create(&user).Error) + + redemption := Redemption{ + Id: 2, + UserId: 1, + Key: "local-redeem-key", + Status: common.RedemptionCodeStatusEnabled, + Quota: 50000, + CreatedTime: common.GetTimestamp(), + } + require.NoError(t, db.Create(&redemption).Error) + + quota, err := Redeem("local-redeem-key", 101) + require.NoError(t, err) + assert.Equal(t, 50000, quota) + + var updatedUser User + require.NoError(t, db.First(&updatedUser, 101).Error) assert.Equal(t, 150000, updatedUser.Quota) }