Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
87 changes: 66 additions & 21 deletions controller/redemption.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ package controller
import (
"net/http"
"strconv"
"strings"
"unicode/utf8"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"

"github.com/gin-gonic/gin"
)
Expand Down Expand Up @@ -85,11 +87,9 @@ func AddRedemption(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
return
}
if redemption.Type != "" && redemption.Type != model.RedemptionTypeQuota {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
if !normalizeRedemptionBenefit(c, &redemption) {
return
}
redemption.Type = model.RedemptionTypeQuota
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
return
Expand All @@ -98,13 +98,15 @@ func AddRedemption(c *gin.Context) {
for i := 0; i < redemption.Count; i++ {
key := common.GetUUID()
cleanRedemption := model.Redemption{
UserId: c.GetInt("id"),
Name: redemption.Name,
Key: key,
CreatedTime: common.GetTimestamp(),
Quota: redemption.Quota,
Type: model.RedemptionTypeQuota,
ExpiredTime: redemption.ExpiredTime,
UserId: c.GetInt("id"),
Name: redemption.Name,
Key: key,
CreatedTime: common.GetTimestamp(),
Quota: redemption.Quota,
Type: redemption.Type,
GroupName: redemption.GroupName,
GroupDurationMinutes: redemption.GroupDurationMinutes,
ExpiredTime: redemption.ExpiredTime,
}
err = cleanRedemption.Insert()
if err != nil {
Expand All @@ -119,10 +121,12 @@ func AddRedemption(c *gin.Context) {
keys = append(keys, key)
}
recordManageAudit(c, "redemption.create", map[string]interface{}{
"name": redemption.Name,
"count": redemption.Count,
"quota": logger.LogQuota(redemption.Quota),
"type": model.RedemptionTypeQuota,
"name": redemption.Name,
"count": redemption.Count,
"quota": logger.LogQuota(redemption.Quota),
"type": redemption.Type,
"group_name": redemption.GroupName,
"group_duration_minutes": redemption.GroupDurationMinutes,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
Expand Down Expand Up @@ -160,20 +164,23 @@ func UpdateRedemption(c *gin.Context) {
return
}
if statusOnly == "" {
if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 {
common.ApiErrorI18n(c, i18n.MsgRedemptionNameLength)
return
}
if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
return
}
// If you add more fields, please also update redemption.Update()
cleanRedemption.Name = redemption.Name
cleanRedemption.Quota = redemption.Quota
if redemption.Type != "" && redemption.Type != model.RedemptionTypeQuota {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
if !normalizeRedemptionBenefit(c, &redemption) {
return
}
cleanRedemption.Type = model.RedemptionTypeQuota
cleanRedemption.GroupName = ""
cleanRedemption.GroupDurationMinutes = 0
cleanRedemption.Name = redemption.Name
cleanRedemption.Quota = redemption.Quota
cleanRedemption.Type = redemption.Type
cleanRedemption.GroupName = redemption.GroupName
cleanRedemption.GroupDurationMinutes = redemption.GroupDurationMinutes
cleanRedemption.ExpiredTime = redemption.ExpiredTime
}
if statusOnly != "" {
Expand Down Expand Up @@ -212,3 +219,41 @@ func validateExpiredTime(c *gin.Context, expired int64) (bool, string) {
}
return true, ""
}

func normalizeRedemptionBenefit(c *gin.Context, redemption *model.Redemption) bool {
if redemption == nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return false
}
if redemption.Type == "" {
redemption.Type = model.RedemptionTypeQuota
}
switch redemption.Type {
case model.RedemptionTypeQuota:
if redemption.Quota <= 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return false
}
redemption.GroupName = ""
redemption.GroupDurationMinutes = 0
case model.RedemptionTypeGroup:
redemption.GroupName = strings.TrimSpace(redemption.GroupName)
if redemption.GroupName == "" || utf8.RuneCountInString(redemption.GroupName) > 64 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return false
}
if _, ok := ratio_setting.GetGroupRatioCopy()[redemption.GroupName]; !ok {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return false
}
if redemption.GroupDurationMinutes < 0 || redemption.GroupDurationMinutes > model.MaxRedemptionGroupDurationMinutes {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return false
}
redemption.Quota = 0
default:
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return false
}
return true
}
73 changes: 67 additions & 6 deletions model/redemption.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

const (
RedemptionTypeQuota = "quota"
RedemptionTypeGroup = "group"
)

type Redemption struct {
Expand All @@ -34,8 +35,10 @@ type Redemption struct {
}

type RedemptionResult struct {
Type string `json:"type"`
Quota int `json:"quota"`
Type string `json:"type"`
Quota int `json:"quota"`
GroupName string `json:"group_name,omitempty"`
GroupExpiresAt int64 `json:"group_expires_at,omitempty"`
}

func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total int64, err error) {
Expand Down Expand Up @@ -110,7 +113,7 @@ func Redeem(key string, userID int) (result RedemptionResult, err error) {
if redemptionType == "" {
redemptionType = RedemptionTypeQuota
}
if redemptionType != RedemptionTypeQuota {
if redemptionType != RedemptionTypeQuota && redemptionType != RedemptionTypeGroup {
return errors.New("unsupported redemption type")
}
update := tx.Model(&Redemption{}).
Expand All @@ -127,8 +130,58 @@ func Redeem(key string, userID int) (result RedemptionResult, err error) {
return errors.New("redemption code is unavailable")
}
result.Type = redemptionType
result.Quota = redemption.Quota
return tx.Model(&User{}).Where("id = ?", userID).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
switch redemptionType {
case RedemptionTypeQuota:
if redemption.Quota <= 0 {
return errors.New("invalid redemption quota")
}
result.Quota = redemption.Quota
return tx.Model(&User{}).Where("id = ?", userID).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
case RedemptionTypeGroup:
expiresAt, err := applyUserGroupEntitlementTx(
tx,
userID,
redemption.GroupName,
redemption.GroupDurationMinutes,
common.GetTimestamp(),
)
if err != nil {
return err
}
if expiresAt > 0 {
var activePaidCount int64
if err := tx.Model(&UserSubscription{}).
Where("user_id = ? AND status = ? AND end_time > ? AND source <> ?", userID, "active", common.GetTimestamp(), "redemption").
Count(&activePaidCount).Error; err != nil {
return err
}
if activePaidCount == 0 {
var plan SubscriptionPlan
if err := tx.Where("enabled = ? AND upgrade_group = ?", true, redemption.GroupName).
Order("sort_order asc, id asc").
First(&plan).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("no enabled subscription plan matches the redemption group")
}
return err
}
plan.NormalizeDefaults()
// The temporary user group is managed by the redemption entitlement.
// Keep the subscription snapshot focused on quota funding so both
// records expire independently without applying the group twice.
plan.UpgradeGroup = ""
plan.DowngradeGroup = ""
if _, err := CreateUserSubscriptionFromPlanTx(tx, userID, &plan, "redemption", expiresAt); err != nil {
return err
}
}
}
result.GroupName = redemption.GroupName
result.GroupExpiresAt = expiresAt
return nil
default:
return errors.New("unsupported redemption type")
}
})
if err != nil {
common.SysError("redemption failed: " + err.Error())
Expand All @@ -137,7 +190,15 @@ func Redeem(key string, userID int) (result RedemptionResult, err error) {
if err := invalidateUserCache(userID); err != nil {
common.SysError("failed to invalidate user cache after redemption: " + err.Error())
}
RecordLog(userID, LogTypeTopup, fmt.Sprintf("Redeemed quota %s, redemption ID %d", logger.LogQuota(redemption.Quota), redemption.Id))
if result.Type == RedemptionTypeGroup {
duration := "permanent"
if redemption.GroupDurationMinutes > 0 {
duration = fmt.Sprintf("%d minutes", redemption.GroupDurationMinutes)
}
RecordLog(userID, LogTypeTopup, fmt.Sprintf("Redeemed group entitlement %s (%s), redemption ID %d", redemption.GroupName, duration, redemption.Id))
} else {
RecordLog(userID, LogTypeTopup, fmt.Sprintf("Redeemed quota %s, redemption ID %d", logger.LogQuota(redemption.Quota), redemption.Id))
}
return result, nil
}

Expand Down
116 changes: 108 additions & 8 deletions model/redemption_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,12 @@ func setupRedeemFixture(t *testing.T, quota int) (userId int, key string) {
t.Helper()
require.NoError(t, DB.AutoMigrate(&Redemption{}))
require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&Redemption{}).Error)
require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&UserSubscription{}).Error)
require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&SubscriptionPlan{}).Error)
t.Cleanup(func() {
require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&Redemption{}).Error)
require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&UserSubscription{}).Error)
require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&SubscriptionPlan{}).Error)
DB.Exec("DELETE FROM users")
DB.Exec("DELETE FROM logs")
})
Expand Down Expand Up @@ -149,26 +153,122 @@ func TestRedeemCreditsQuotaExactlyOnce(t *testing.T) {
assert.Equal(t, 500, user.Quota)
}

func TestRedeemRejectsLegacyGroupEntitlement(t *testing.T) {
func TestRedeemGrantsTemporaryGroupEntitlement(t *testing.T) {
userID, key := setupRedeemFixture(t, 0)
plan := &SubscriptionPlan{
Title: "Moderate",
Enabled: true,
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
UpgradeGroup: "Moderate",
DowngradeGroup: "Free",
TotalAmount: 5000,
FiveHourQuota: 1000,
QuotaResetPeriod: SubscriptionResetWeekly,
}
plan.NormalizeDefaults()
require.NoError(t, DB.Create(plan).Error)
require.NoError(t, DB.Model(&Redemption{}).Where(commonKeyCol+" = ?", key).Updates(map[string]interface{}{
"type": "group",
"group_name": "pro",
"type": RedemptionTypeGroup,
"group_name": "Moderate",
"group_duration_minutes": 60,
}).Error)

_, err := Redeem(key, userID)
require.Error(t, err)
before := common.GetTimestamp()
result, err := Redeem(key, userID)
require.NoError(t, err)
assert.Equal(t, RedemptionTypeGroup, result.Type)
assert.Equal(t, "Moderate", result.GroupName)
assert.GreaterOrEqual(t, result.GroupExpiresAt, before+60*60)
assert.LessOrEqual(t, result.GroupExpiresAt, common.GetTimestamp()+60*60)

var user User
require.NoError(t, DB.First(&user, "id = ?", userID).Error)
assert.Equal(t, "Free", user.Group)
assert.Equal(t, "Moderate", user.Group)
assert.Equal(t, "Free", user.GroupRestore)
assert.Equal(t, result.GroupExpiresAt, user.GroupExpiresAt)
assert.Zero(t, user.Quota)

var subscription UserSubscription
require.NoError(t, DB.First(&subscription, "user_id = ?", userID).Error)
assert.Equal(t, plan.Id, subscription.PlanId)
assert.Equal(t, int64(5000), subscription.AmountTotal)
assert.Equal(t, int64(1000), subscription.FiveHourQuota)
assert.Equal(t, result.GroupExpiresAt, subscription.EndTime)
assert.Equal(t, "redemption", subscription.Source)
assert.Empty(t, subscription.UpgradeGroup)
assert.Empty(t, subscription.DowngradeGroup)

var redemption Redemption
require.NoError(t, DB.First(&redemption, commonKeyCol+" = ?", key).Error)
assert.Equal(t, common.RedemptionCodeStatusEnabled, redemption.Status)
assert.Zero(t, redemption.UsedUserId)
assert.Equal(t, common.RedemptionCodeStatusUsed, redemption.Status)
assert.Equal(t, userID, redemption.UsedUserId)

_, err = Redeem(key, userID)
require.Error(t, err)
}

func TestRedeemGrantsPermanentGroupEntitlement(t *testing.T) {
userID, key := setupRedeemFixture(t, 0)
require.NoError(t, DB.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{
"group": "Light",
"group_restore": "Free",
"group_expires_at": common.GetTimestamp() + 300,
}).Error)
require.NoError(t, DB.Model(&Redemption{}).Where(commonKeyCol+" = ?", key).Updates(map[string]interface{}{
"type": RedemptionTypeGroup,
"group_name": "Heavy",
"group_duration_minutes": 0,
}).Error)

result, err := Redeem(key, userID)
require.NoError(t, err)
assert.Equal(t, RedemptionTypeGroup, result.Type)
assert.Equal(t, "Heavy", result.GroupName)
assert.Zero(t, result.GroupExpiresAt)

var user User
require.NoError(t, DB.First(&user, "id = ?", userID).Error)
assert.Equal(t, "Heavy", user.Group)
assert.Empty(t, user.GroupRestore)
assert.Zero(t, user.GroupExpiresAt)
}

func TestRedeemExtendsMatchingTemporaryGroupEntitlement(t *testing.T) {
userID, key := setupRedeemFixture(t, 0)
plan := &SubscriptionPlan{
Title: "Moderate",
Enabled: true,
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
UpgradeGroup: "Moderate",
DowngradeGroup: "Free",
TotalAmount: 5000,
FiveHourQuota: 1000,
QuotaResetPeriod: SubscriptionResetWeekly,
}
plan.NormalizeDefaults()
require.NoError(t, DB.Create(plan).Error)
initialExpiry := common.GetTimestamp() + 120
require.NoError(t, DB.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{
"group": "Moderate",
"group_restore": "Free",
"group_expires_at": initialExpiry,
}).Error)
require.NoError(t, DB.Model(&Redemption{}).Where(commonKeyCol+" = ?", key).Updates(map[string]interface{}{
"type": RedemptionTypeGroup,
"group_name": "Moderate",
"group_duration_minutes": 10,
}).Error)

result, err := Redeem(key, userID)
require.NoError(t, err)
assert.Equal(t, initialExpiry+600, result.GroupExpiresAt)

var user User
require.NoError(t, DB.First(&user, "id = ?", userID).Error)
assert.Equal(t, "Free", user.GroupRestore)
assert.Equal(t, initialExpiry+600, user.GroupExpiresAt)
}

// Exactly one of several concurrent redeems of the same code may win, and
Expand Down
Loading