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 <noreply@anthropic.com>master
| @@ -1029,6 +1029,10 @@ func TopUp(c *gin.Context) { | |||||
| } | } | ||||
| quota, err := model.Redeem(req.Key, id) | quota, err := model.Redeem(req.Key, id) | ||||
| if err != nil { | if err != nil { | ||||
| if errors.Is(err, model.ErrSyncedUserRedeemDenied) { | |||||
| common.ApiErrorI18n(c, i18n.MsgRedemptionSyncedUserDenied) | |||||
| return | |||||
| } | |||||
| if errors.Is(err, model.ErrRedeemFailed) { | if errors.Is(err, model.ErrRedeemFailed) { | ||||
| common.ApiErrorI18n(c, i18n.MsgRedeemFailed) | common.ApiErrorI18n(c, i18n.MsgRedeemFailed) | ||||
| return | return | ||||
| @@ -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) | |||||
| } | |||||
| @@ -54,6 +54,7 @@ const ( | |||||
| MsgRedemptionUsed = "redemption.used" | MsgRedemptionUsed = "redemption.used" | ||||
| MsgRedemptionExpired = "redemption.expired" | MsgRedemptionExpired = "redemption.expired" | ||||
| MsgRedemptionFailed = "redemption.failed" | MsgRedemptionFailed = "redemption.failed" | ||||
| MsgRedemptionSyncedUserDenied = "redemption.synced_user_denied" | |||||
| MsgRedemptionNotProvided = "redemption.not_provided" | MsgRedemptionNotProvided = "redemption.not_provided" | ||||
| MsgRedemptionExpireTimeInvalid = "redemption.expire_time_invalid" | MsgRedemptionExpireTimeInvalid = "redemption.expire_time_invalid" | ||||
| ) | ) | ||||
| @@ -46,6 +46,7 @@ redemption.invalid: "Invalid redemption code" | |||||
| redemption.used: "This redemption code has been used" | redemption.used: "This redemption code has been used" | ||||
| redemption.expired: "This redemption code has expired" | redemption.expired: "This redemption code has expired" | ||||
| redemption.failed: "Redemption failed, please try again later" | 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.not_provided: "Redemption code not provided" | ||||
| redemption.expire_time_invalid: "Expiration time cannot be earlier than current time" | redemption.expire_time_invalid: "Expiration time cannot be earlier than current time" | ||||
| @@ -47,6 +47,7 @@ redemption.invalid: "无效的兑换码" | |||||
| redemption.used: "该兑换码已被使用" | redemption.used: "该兑换码已被使用" | ||||
| redemption.expired: "该兑换码已过期" | redemption.expired: "该兑换码已过期" | ||||
| redemption.failed: "兑换失败,请稍后重试" | redemption.failed: "兑换失败,请稍后重试" | ||||
| redemption.synced_user_denied: "同步用户不允许使用兑换码" | |||||
| redemption.not_provided: "未提供兑换码" | redemption.not_provided: "未提供兑换码" | ||||
| redemption.expire_time_invalid: "过期时间不能早于当前时间" | redemption.expire_time_invalid: "过期时间不能早于当前时间" | ||||
| @@ -47,6 +47,7 @@ redemption.invalid: "無效的兌換碼" | |||||
| redemption.used: "該兌換碼已被使用" | redemption.used: "該兌換碼已被使用" | ||||
| redemption.expired: "該兌換碼已過期" | redemption.expired: "該兌換碼已過期" | ||||
| redemption.failed: "兌換失敗,請稍後重試" | redemption.failed: "兌換失敗,請稍後重試" | ||||
| redemption.synced_user_denied: "同步使用者不允許使用兌換碼" | |||||
| redemption.not_provided: "未提供兌換碼" | redemption.not_provided: "未提供兌換碼" | ||||
| redemption.expire_time_invalid: "過期時間不能早於當前時間" | redemption.expire_time_invalid: "過期時間不能早於當前時間" | ||||
| @@ -14,6 +14,9 @@ import ( | |||||
| // ErrRedeemFailed is returned when redemption fails due to database error | // ErrRedeemFailed is returned when redemption fails due to database error | ||||
| var ErrRedeemFailed = errors.New("redeem.failed") | 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 { | type Redemption struct { | ||||
| Id int `json:"id"` | Id int `json:"id"` | ||||
| UserId int `json:"user_id"` | UserId int `json:"user_id"` | ||||
| @@ -123,6 +126,15 @@ func Redeem(key string, userId int) (quota int, err error) { | |||||
| if userId == 0 { | if userId == 0 { | ||||
| return 0, errors.New("无效的 user id") | 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{} | redemption := &Redemption{} | ||||
| keyCol := "`key`" | keyCol := "`key`" | ||||
| @@ -241,15 +241,54 @@ func TestRedeem_SyncedUser(t *testing.T) { | |||||
| // 执行兑换 | // 执行兑换 | ||||
| quota, err := Redeem("synced-redeem-key", 100) | 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 | var updatedUser User | ||||
| require.NoError(t, db.First(&updatedUser, 100).Error) | 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) | assert.Equal(t, 150000, updatedUser.Quota) | ||||
| } | } | ||||