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) | |||
| 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 | |||
| @@ -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" | |||
| MsgRedemptionExpired = "redemption.expired" | |||
| MsgRedemptionFailed = "redemption.failed" | |||
| MsgRedemptionSyncedUserDenied = "redemption.synced_user_denied" | |||
| MsgRedemptionNotProvided = "redemption.not_provided" | |||
| MsgRedemptionExpireTimeInvalid = "redemption.expire_time_invalid" | |||
| ) | |||
| @@ -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" | |||
| @@ -47,6 +47,7 @@ redemption.invalid: "无效的兑换码" | |||
| redemption.used: "该兑换码已被使用" | |||
| redemption.expired: "该兑换码已过期" | |||
| redemption.failed: "兑换失败,请稍后重试" | |||
| redemption.synced_user_denied: "同步用户不允许使用兑换码" | |||
| redemption.not_provided: "未提供兑换码" | |||
| redemption.expire_time_invalid: "过期时间不能早于当前时间" | |||
| @@ -47,6 +47,7 @@ redemption.invalid: "無效的兌換碼" | |||
| redemption.used: "該兌換碼已被使用" | |||
| redemption.expired: "該兌換碼已過期" | |||
| redemption.failed: "兌換失敗,請稍後重試" | |||
| redemption.synced_user_denied: "同步使用者不允許使用兌換碼" | |||
| redemption.not_provided: "未提供兌換碼" | |||
| redemption.expire_time_invalid: "過期時間不能早於當前時間" | |||
| @@ -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`" | |||
| @@ -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) | |||
| } | |||