diff --git a/controller/redemption.go b/controller/redemption.go
index 76c35bc3..a5a17670 100644
--- a/controller/redemption.go
+++ b/controller/redemption.go
@@ -3,6 +3,7 @@ package controller
import (
"net/http"
"strconv"
+ "strings"
"unicode/utf8"
"github.com/QuantumNous/new-api/common"
@@ -19,10 +20,10 @@ func GetAllRedemptions(c *gin.Context) {
common.ApiError(c, err)
return
}
+ enrichRedemptions(redemptions)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(redemptions)
common.ApiSuccess(c, pageInfo)
- return
}
func SearchRedemptions(c *gin.Context) {
@@ -33,10 +34,10 @@ func SearchRedemptions(c *gin.Context) {
common.ApiError(c, err)
return
}
+ enrichRedemptions(redemptions)
pageInfo.SetTotal(int(total))
pageInfo.SetItems(redemptions)
common.ApiSuccess(c, pageInfo)
- return
}
func GetRedemption(c *gin.Context) {
@@ -50,12 +51,12 @@ func GetRedemption(c *gin.Context) {
common.ApiError(c, err)
return
}
+ enrichRedemption(redemption)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": redemption,
})
- return
}
func AddRedemption(c *gin.Context) {
@@ -65,8 +66,7 @@ func AddRedemption(c *gin.Context) {
common.ApiError(c, err)
return
}
- if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 {
- common.ApiErrorI18n(c, i18n.MsgRedemptionNameLength)
+ if !validateRedemptionPayload(c, &redemption, true) {
return
}
if redemption.Count <= 0 {
@@ -77,10 +77,6 @@ func AddRedemption(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax)
return
}
- if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
- c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
- return
- }
var keys []string
for i := 0; i < redemption.Count; i++ {
key := common.GetUUID()
@@ -90,6 +86,9 @@ func AddRedemption(c *gin.Context) {
Key: key,
CreatedTime: common.GetTimestamp(),
Quota: redemption.Quota,
+ RedeemType: redemption.RedeemType,
+ PlanId: redemption.PlanId,
+ SourceNote: redemption.SourceNote,
ExpiredTime: redemption.ExpiredTime,
}
err = cleanRedemption.Insert()
@@ -109,7 +108,6 @@ func AddRedemption(c *gin.Context) {
"message": "",
"data": keys,
})
- return
}
func DeleteRedemption(c *gin.Context) {
@@ -123,7 +121,6 @@ func DeleteRedemption(c *gin.Context) {
"success": true,
"message": "",
})
- return
}
func UpdateRedemption(c *gin.Context) {
@@ -140,13 +137,14 @@ func UpdateRedemption(c *gin.Context) {
return
}
if statusOnly == "" {
- if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
- c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
+ if !validateRedemptionPayload(c, &redemption, true) {
return
}
- // If you add more fields, please also update redemption.Update()
cleanRedemption.Name = redemption.Name
cleanRedemption.Quota = redemption.Quota
+ cleanRedemption.RedeemType = redemption.RedeemType
+ cleanRedemption.PlanId = redemption.PlanId
+ cleanRedemption.SourceNote = redemption.SourceNote
cleanRedemption.ExpiredTime = redemption.ExpiredTime
}
if statusOnly != "" {
@@ -157,12 +155,12 @@ func UpdateRedemption(c *gin.Context) {
common.ApiError(c, err)
return
}
+ enrichRedemption(cleanRedemption)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": cleanRedemption,
})
- return
}
func DeleteInvalidRedemption(c *gin.Context) {
@@ -176,7 +174,6 @@ func DeleteInvalidRedemption(c *gin.Context) {
"message": "",
"data": rows,
})
- return
}
func validateExpiredTime(c *gin.Context, expired int64) (bool, string) {
@@ -185,3 +182,78 @@ func validateExpiredTime(c *gin.Context, expired int64) (bool, string) {
}
return true, ""
}
+
+func validateRedemptionPayload(c *gin.Context, redemption *model.Redemption, requireName bool) bool {
+ if redemption == nil {
+ common.ApiErrorMsg(c, "invalid redemption payload")
+ return false
+ }
+ redemption.Name = strings.TrimSpace(redemption.Name)
+ redemption.SourceNote = strings.TrimSpace(redemption.SourceNote)
+ redemption.RedeemType = model.NormalizeRedemptionType(redemption.RedeemType)
+ if redemption.RedeemType == "" {
+ common.ApiErrorMsg(c, "invalid redeem type")
+ return false
+ }
+ if requireName && (utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20) {
+ common.ApiErrorI18n(c, i18n.MsgRedemptionNameLength)
+ return false
+ }
+ if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid {
+ c.JSON(http.StatusOK, gin.H{"success": false, "message": msg})
+ return false
+ }
+ switch redemption.RedeemType {
+ case model.RedemptionTypeQuota:
+ if redemption.Quota <= 0 {
+ common.ApiErrorMsg(c, "quota must be greater than 0")
+ return false
+ }
+ redemption.PlanId = 0
+ case model.RedemptionTypeSubscription:
+ if redemption.PlanId <= 0 {
+ common.ApiErrorMsg(c, "plan_id is required")
+ return false
+ }
+ plan, err := model.GetSubscriptionPlanById(redemption.PlanId)
+ if err != nil || plan == nil {
+ common.ApiErrorMsg(c, "subscription plan not found")
+ return false
+ }
+ redemption.Quota = 0
+ }
+ return true
+}
+
+func enrichRedemptions(redemptions []*model.Redemption) {
+ if len(redemptions) == 0 {
+ return
+ }
+ planTitles := make(map[int]string)
+ for _, redemption := range redemptions {
+ if redemption == nil || model.NormalizeRedemptionType(redemption.RedeemType) != model.RedemptionTypeSubscription || redemption.PlanId <= 0 {
+ continue
+ }
+ if _, ok := planTitles[redemption.PlanId]; ok {
+ redemption.PlanTitle = planTitles[redemption.PlanId]
+ continue
+ }
+ plan, err := model.GetSubscriptionPlanById(redemption.PlanId)
+ if err != nil || plan == nil {
+ continue
+ }
+ planTitles[redemption.PlanId] = plan.Title
+ redemption.PlanTitle = plan.Title
+ }
+}
+
+func enrichRedemption(redemption *model.Redemption) {
+ if redemption == nil || model.NormalizeRedemptionType(redemption.RedeemType) != model.RedemptionTypeSubscription || redemption.PlanId <= 0 {
+ return
+ }
+ plan, err := model.GetSubscriptionPlanById(redemption.PlanId)
+ if err != nil || plan == nil {
+ return
+ }
+ redemption.PlanTitle = plan.Title
+}
diff --git a/controller/user.go b/controller/user.go
index 8229d0d2..e2a37f3c 100644
--- a/controller/user.go
+++ b/controller/user.go
@@ -1025,7 +1025,7 @@ func TopUp(c *gin.Context) {
common.ApiError(c, err)
return
}
- quota, err := model.Redeem(req.Key, id)
+ result, err := model.Redeem(req.Key, id)
if err != nil {
if errors.Is(err, model.ErrRedeemFailed) {
common.ApiErrorI18n(c, i18n.MsgRedeemFailed)
@@ -1037,7 +1037,7 @@ func TopUp(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
- "data": quota,
+ "data": result,
})
}
diff --git a/model/main.go b/model/main.go
index f37cb667..4a4bc6f7 100644
--- a/model/main.go
+++ b/model/main.go
@@ -284,6 +284,9 @@ func migrateDB() error {
if err != nil {
return err
}
+ if err := migrateRedemptionRedeemType(); err != nil {
+ return err
+ }
if common.UsingSQLite {
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
return err
@@ -352,6 +355,9 @@ func migrateDBFast() error {
return err
}
}
+ if err := migrateRedemptionRedeemType(); err != nil {
+ return err
+ }
if common.UsingSQLite {
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
return err
@@ -365,6 +371,18 @@ func migrateDBFast() error {
return nil
}
+func migrateRedemptionRedeemType() error {
+ if !DB.Migrator().HasTable(&Redemption{}) {
+ return nil
+ }
+ if !DB.Migrator().HasColumn(&Redemption{}, "redeem_type") {
+ return nil
+ }
+ return DB.Model(&Redemption{}).
+ Where("redeem_type = '' OR redeem_type IS NULL").
+ Update("redeem_type", RedemptionTypeQuota).Error
+}
+
func migrateLOGDB() error {
var err error
if err = LOG_DB.AutoMigrate(&Log{}); err != nil {
diff --git a/model/redemption.go b/model/redemption.go
index 378976a3..495b7cda 100644
--- a/model/redemption.go
+++ b/model/redemption.go
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"strconv"
+ "strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
@@ -14,6 +15,19 @@ import (
// ErrRedeemFailed is returned when redemption fails due to database error
var ErrRedeemFailed = errors.New("redeem.failed")
+const (
+ RedemptionTypeQuota = "quota"
+ RedemptionTypeSubscription = "subscription"
+)
+
+type RedeemResult struct {
+ RedeemType string `json:"redeem_type"`
+ Quota int `json:"quota,omitempty"`
+ PlanId int `json:"plan_id,omitempty"`
+ PlanTitle string `json:"plan_title,omitempty"`
+ SubscriptionId int `json:"subscription_id,omitempty"`
+}
+
type Redemption struct {
Id int `json:"id"`
UserId int `json:"user_id"`
@@ -21,16 +35,30 @@ type Redemption struct {
Status int `json:"status" gorm:"default:1"`
Name string `json:"name" gorm:"index"`
Quota int `json:"quota" gorm:"default:100"`
+ RedeemType string `json:"redeem_type" gorm:"type:varchar(32);not null;default:'quota'"`
+ PlanId int `json:"plan_id" gorm:"default:0;index"`
+ SourceNote string `json:"source_note" gorm:"type:varchar(255);default:''"`
+ PlanTitle string `json:"plan_title" gorm:"-:all"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"`
Count int `json:"count" gorm:"-:all"` // only for api request
UsedUserId int `json:"used_user_id"`
DeletedAt gorm.DeletedAt `gorm:"index"`
- ExpiredTime int64 `json:"expired_time" gorm:"bigint"` // 过期时间,0 表示不过期
+ ExpiredTime int64 `json:"expired_time" gorm:"bigint"`
+}
+
+func NormalizeRedemptionType(raw string) string {
+ switch strings.TrimSpace(strings.ToLower(raw)) {
+ case "", RedemptionTypeQuota:
+ return RedemptionTypeQuota
+ case RedemptionTypeSubscription:
+ return RedemptionTypeSubscription
+ default:
+ return ""
+ }
}
func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total int64, err error) {
- // 开始事务
tx := DB.Begin()
if tx.Error != nil {
return nil, 0, tx.Error
@@ -41,21 +69,18 @@ func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total
}
}()
- // 获取总数
err = tx.Model(&Redemption{}).Count(&total).Error
if err != nil {
tx.Rollback()
return nil, 0, err
}
- // 获取分页数据
err = tx.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error
if err != nil {
tx.Rollback()
return nil, 0, err
}
- // 提交事务
if err = tx.Commit().Error; err != nil {
return nil, 0, err
}
@@ -74,24 +99,20 @@ func SearchRedemptions(keyword string, startIdx int, num int) (redemptions []*Re
}
}()
- // Build query based on keyword type
query := tx.Model(&Redemption{})
- // Only try to convert to ID if the string represents a valid integer
if id, err := strconv.Atoi(keyword); err == nil {
query = query.Where("id = ? OR name LIKE ?", id, keyword+"%")
} else {
query = query.Where("name LIKE ?", keyword+"%")
}
- // Get total count
err = query.Count(&total).Error
if err != nil {
tx.Rollback()
return nil, 0, err
}
- // Get paginated data
err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error
if err != nil {
tx.Rollback()
@@ -107,22 +128,25 @@ func SearchRedemptions(keyword string, startIdx int, num int) (redemptions []*Re
func GetRedemptionById(id int) (*Redemption, error) {
if id == 0 {
- return nil, errors.New("id 为空!")
+ return nil, errors.New("id is empty")
}
redemption := Redemption{Id: id}
- var err error = nil
- err = DB.First(&redemption, "id = ?", id).Error
+ err := DB.First(&redemption, "id = ?", id).Error
return &redemption, err
}
-func Redeem(key string, userId int) (quota int, err error) {
+func Redeem(key string, userId int) (result *RedeemResult, err error) {
if key == "" {
- return 0, errors.New("未提供兑换码")
+ return nil, errors.New("redemption code is required")
}
if userId == 0 {
- return 0, errors.New("无效的 user id")
+ return nil, errors.New("invalid user id")
}
+
redemption := &Redemption{}
+ result = &RedeemResult{}
+ logMessage := ""
+ upgradeGroup := ""
keyCol := "`key`"
if common.UsingPostgreSQL {
@@ -132,18 +156,50 @@ func Redeem(key string, userId int) (quota int, err error) {
err = DB.Transaction(func(tx *gorm.DB) error {
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(keyCol+" = ?", key).First(redemption).Error
if err != nil {
- return errors.New("无效的兑换码")
+ return errors.New("invalid redemption code")
}
if redemption.Status != common.RedemptionCodeStatusEnabled {
- return errors.New("该兑换码已被使用")
+ return errors.New("redemption code is unavailable")
}
if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() {
- return errors.New("该兑换码已过期")
+ return errors.New("redemption code is expired")
}
- err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
- if err != nil {
- return err
+
+ redeemType := NormalizeRedemptionType(redemption.RedeemType)
+ if redeemType == "" {
+ return errors.New("invalid redemption type")
+ }
+
+ switch redeemType {
+ case RedemptionTypeQuota:
+ err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error
+ if err != nil {
+ return err
+ }
+ result.RedeemType = RedemptionTypeQuota
+ result.Quota = redemption.Quota
+ logMessage = fmt.Sprintf("Redeemed quota %s via redemption code ID %d", logger.LogQuota(redemption.Quota), redemption.Id)
+ case RedemptionTypeSubscription:
+ if redemption.PlanId <= 0 {
+ return errors.New("invalid subscription plan id")
+ }
+ plan, err := getSubscriptionPlanByIdTx(tx, redemption.PlanId)
+ if err != nil {
+ return err
+ }
+ sub, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, "redeem")
+ if err != nil {
+ return err
+ }
+ result.RedeemType = RedemptionTypeSubscription
+ result.PlanId = plan.Id
+ result.PlanTitle = plan.Title
+ result.SubscriptionId = sub.Id
+ upgradeGroup = strings.TrimSpace(plan.UpgradeGroup)
+ logMessage = fmt.Sprintf("Redeemed subscription plan %s via redemption code ID %d", plan.Title, redemption.Id)
}
+
+ redemption.RedeemType = redeemType
redemption.RedeemedTime = common.GetTimestamp()
redemption.Status = common.RedemptionCodeStatusUsed
redemption.UsedUserId = userId
@@ -152,39 +208,42 @@ func Redeem(key string, userId int) (quota int, err error) {
})
if err != nil {
common.SysError("redemption failed: " + err.Error())
- return 0, ErrRedeemFailed
+ return nil, ErrRedeemFailed
}
- RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s,兑换码ID %d", logger.LogQuota(redemption.Quota), redemption.Id))
- return redemption.Quota, nil
+
+ if upgradeGroup != "" {
+ _ = UpdateUserGroupCache(userId, upgradeGroup)
+ }
+ if logMessage != "" {
+ RecordLog(userId, LogTypeTopup, logMessage)
+ }
+ return result, nil
}
func (redemption *Redemption) Insert() error {
- var err error
- err = DB.Create(redemption).Error
- return err
+ redemption.RedeemType = NormalizeRedemptionType(redemption.RedeemType)
+ if redemption.RedeemType == "" {
+ redemption.RedeemType = RedemptionTypeQuota
+ }
+ return DB.Create(redemption).Error
}
func (redemption *Redemption) SelectUpdate() error {
- // This can update zero values
return DB.Model(redemption).Select("redeemed_time", "status").Updates(redemption).Error
}
// Update Make sure your token's fields is completed, because this will update non-zero values
func (redemption *Redemption) Update() error {
- var err error
- err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time").Updates(redemption).Error
- return err
+ return DB.Model(redemption).Select("name", "status", "quota", "redeem_type", "plan_id", "source_note", "redeemed_time", "expired_time").Updates(redemption).Error
}
func (redemption *Redemption) Delete() error {
- var err error
- err = DB.Delete(redemption).Error
- return err
+ return DB.Delete(redemption).Error
}
func DeleteRedemptionById(id int) (err error) {
if id == 0 {
- return errors.New("id 为空!")
+ return errors.New("id is empty")
}
redemption := Redemption{Id: id}
err = DB.Where(redemption).First(&redemption).Error
diff --git a/web/src/components/table/redemptions/RedemptionsColumnDefs.jsx b/web/src/components/table/redemptions/RedemptionsColumnDefs.jsx
index efe1114c..6dcb32fb 100644
--- a/web/src/components/table/redemptions/RedemptionsColumnDefs.jsx
+++ b/web/src/components/table/redemptions/RedemptionsColumnDefs.jsx
@@ -25,11 +25,10 @@ import {
REDEMPTION_STATUS,
REDEMPTION_STATUS_MAP,
REDEMPTION_ACTIONS,
+ REDEMPTION_REDEEM_TYPE,
+ REDEMPTION_REDEEM_TYPE_MAP,
} from '../../../constants/redemption.constants';
-/**
- * Check if redemption code is expired
- */
export const isExpired = (record) => {
return (
record.status === REDEMPTION_STATUS.UNUSED &&
@@ -38,21 +37,15 @@ export const isExpired = (record) => {
);
};
-/**
- * Render timestamp
- */
const renderTimestamp = (timestamp) => {
return <>{timestamp2string(timestamp)}>;
};
-/**
- * Render redemption code status
- */
const renderStatus = (status, record, t) => {
if (isExpired(record)) {
return (
{t('兑换码创建成功,是否下载兑换码?')}
-{t('兑换码将以文本文件的形式下载,文件名为兑换码的名称。')}
+{t('Download the generated redemption codes?')}
+{t('Codes will be downloaded as a text file named after this redemption.')}