Browse Source

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 <noreply@anthropic.com>
master
fengsilin 1 week ago
parent
commit
23f9ed4ccd
8 changed files with 170 additions and 5 deletions
  1. +4
    -0
      controller/user.go
  2. +106
    -0
      controller/user_topup_test.go
  3. +1
    -0
      i18n/keys.go
  4. +1
    -0
      i18n/locales/en.yaml
  5. +1
    -0
      i18n/locales/zh-CN.yaml
  6. +1
    -0
      i18n/locales/zh-TW.yaml
  7. +12
    -0
      model/redemption.go
  8. +44
    -5
      model/redemption_test.go

+ 4
- 0
controller/user.go View File

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


+ 106
- 0
controller/user_topup_test.go View File

@@ -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)
}

+ 1
- 0
i18n/keys.go View File

@@ -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"
) )


+ 1
- 0
i18n/locales/en.yaml View File

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




+ 1
- 0
i18n/locales/zh-CN.yaml View File

@@ -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: "过期时间不能早于当前时间"




+ 1
- 0
i18n/locales/zh-TW.yaml View File

@@ -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: "過期時間不能早於當前時間"




+ 12
- 0
model/redemption.go View File

@@ -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`"


+ 44
- 5
model/redemption_test.go View File

@@ -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)
} }




Loading…
Cancel
Save