diff --git a/controller/redemption.go b/controller/redemption.go index d656469..78b844a 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" @@ -10,6 +11,7 @@ import ( "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" ) @@ -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 @@ -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 { @@ -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, @@ -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 != "" { @@ -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 +} diff --git a/model/redemption.go b/model/redemption.go index 0bd345d..69176fe 100644 --- a/model/redemption.go +++ b/model/redemption.go @@ -13,6 +13,7 @@ import ( const ( RedemptionTypeQuota = "quota" + RedemptionTypeGroup = "group" ) type Redemption struct { @@ -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) { @@ -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{}). @@ -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()) @@ -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 } diff --git a/model/redemption_test.go b/model/redemption_test.go index d353a51..dd182c7 100644 --- a/model/redemption_test.go +++ b/model/redemption_test.go @@ -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") }) @@ -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 diff --git a/model/subscription.go b/model/subscription.go index 1a6670f..771766e 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -590,10 +590,18 @@ func downgradeUserGroupForSubscriptionTx(tx *gorm.DB, sub *UserSubscription, now if downgradeGroup == "" && upgradeGroup == "" { return "", nil } - currentGroup, err := getUserGroupByIdTx(tx, sub.UserId) + user, err := loadUserGroupStateForUpdateTx(tx, sub.UserId, now) if err != nil { return "", err } + currentGroup := strings.TrimSpace(user.Group) + temporaryEntitlementActive := user.GroupExpiresAt > now + if temporaryEntitlementActive { + currentGroup = strings.TrimSpace(user.GroupRestore) + if currentGroup == "" { + currentGroup = "Free" + } + } // If another active upgraded subscription exists, keep the current group. var activeSub UserSubscription activeQuery := tx.Where("user_id = ? AND status = ? AND end_time > ? AND id <> ? AND upgrade_group <> ''", @@ -617,6 +625,13 @@ func downgradeUserGroupForSubscriptionTx(tx *gorm.DB, sub *UserSubscription, now if target == "" || target == currentGroup { return "", nil } + if temporaryEntitlementActive { + if err := tx.Model(&User{}).Where("id = ?", sub.UserId). + Update("group_restore", target).Error; err != nil { + return "", err + } + return "", nil + } if err := tx.Model(&User{}).Where("id = ?", sub.UserId). Update("group", target).Error; err != nil { return "", err @@ -624,7 +639,7 @@ func downgradeUserGroupForSubscriptionTx(tx *gorm.DB, sub *UserSubscription, now return target, nil } -func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *SubscriptionPlan, source string) (*UserSubscription, error) { +func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *SubscriptionPlan, source string, endTimeOverride ...int64) (*UserSubscription, error) { if tx == nil { return nil, errors.New("tx is nil") } @@ -634,6 +649,9 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio if userId <= 0 { return nil, errors.New("invalid user id") } + if len(endTimeOverride) > 1 { + return nil, errors.New("multiple subscription end time overrides") + } if err := validateSubscriptionQuotaValue("five hour quota", plan.FiveHourQuota, true); err != nil { return nil, err } @@ -756,6 +774,15 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio return nil, err } } + if len(endTimeOverride) == 1 { + override := endTimeOverride[0] + if override <= nowUnix { + return nil, errors.New("subscription end time override must be in the future") + } + if selection.Current == nil || (source == "redemption" && selection.Current.Source == "redemption" && override > endUnix) { + endUnix = override + } + } lastReset := int64(0) nextReset := int64(0) if selection.Current != nil { @@ -770,14 +797,26 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio upgradeGroup := strings.TrimSpace(plan.UpgradeGroup) prevGroup := "" if upgradeGroup != "" { - currentGroup, err := getUserGroupByIdTx(tx, userId) + user, err := loadUserGroupStateForUpdateTx(tx, userId, nowUnix) if err != nil { return nil, err } + currentGroup := strings.TrimSpace(user.Group) + temporaryEntitlementActive := user.GroupExpiresAt > nowUnix + if temporaryEntitlementActive { + currentGroup = strings.TrimSpace(user.GroupRestore) + if currentGroup == "" { + currentGroup = "Free" + } + } if currentGroup != upgradeGroup { prevGroup = currentGroup + column := "group" + if temporaryEntitlementActive { + column = "group_restore" + } if err := tx.Model(&User{}).Where("id = ?", userId). - Update("group", upgradeGroup).Error; err != nil { + Update(column, upgradeGroup).Error; err != nil { return nil, err } } @@ -928,7 +967,7 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string, expectedP return err } if upgradeGroup != "" && logUserId > 0 { - _ = UpdateUserGroupCache(logUserId, upgradeGroup) + _ = invalidateUserCache(logUserId) } if logUserId > 0 { msg := fmt.Sprintf("订阅购买成功,套餐: %s,支付金额: %.2f,支付方式: %s", logPlanTitle, logMoney, logPaymentMethod) @@ -1015,7 +1054,7 @@ func AdminBindSubscription(userId int, planId int, sourceNote string) (string, e return "", err } if strings.TrimSpace(plan.UpgradeGroup) != "" { - _ = UpdateUserGroupCache(userId, plan.UpgradeGroup) + _ = invalidateUserCache(userId) return fmt.Sprintf("用户分组将升级到 %s", plan.UpgradeGroup), nil } return "", nil @@ -1145,7 +1184,7 @@ func PurchaseSubscriptionWithBalance(userId int, planId int) (*SubscriptionBalan } } if upgradeGroup != "" { - _ = UpdateUserGroupCache(userId, upgradeGroup) + _ = invalidateUserCache(userId) } msg := fmt.Sprintf("使用余额购买订阅成功,套餐: %s,支付金额: %.2f,扣除额度: %d", logPlanTitle, logMoney, chargedQuota) RecordLog(userId, LogTypeTopup, msg) @@ -1588,10 +1627,18 @@ func ExpireDueSubscriptions(limit int) (int, error) { if expiredQuery.Error != nil || expiredQuery.RowsAffected == 0 { return nil } - currentGroup, err := getUserGroupByIdTx(tx, userId) + user, err := loadUserGroupStateForUpdateTx(tx, userId, now) if err != nil { return err } + currentGroup := strings.TrimSpace(user.Group) + temporaryEntitlementActive := user.GroupExpiresAt > now + if temporaryEntitlementActive { + currentGroup = strings.TrimSpace(user.GroupRestore) + if currentGroup == "" { + currentGroup = "Free" + } + } // An explicit downgrade group takes precedence; otherwise revert to the // group held before purchase (legacy behavior, only when the subscription // actually elevated the user). @@ -1610,6 +1657,10 @@ func ExpireDueSubscriptions(limit int) (int, error) { if target == "" || target == currentGroup { return nil } + if temporaryEntitlementActive { + return tx.Model(&User{}).Where("id = ?", userId). + Update("group_restore", target).Error + } if err := tx.Model(&User{}).Where("id = ?", userId). Update("group", target).Error; err != nil { return err diff --git a/model/user_group.go b/model/user_group.go index 459011f..a91adee 100644 --- a/model/user_group.go +++ b/model/user_group.go @@ -2,12 +2,93 @@ package model import ( "errors" + "math" + "strings" "github.com/QuantumNous/new-api/common" "gorm.io/gorm" ) +const MaxRedemptionGroupDurationMinutes int64 = 10 * 365 * 24 * 60 + +func loadUserGroupStateForUpdateTx(tx *gorm.DB, userID int, now int64) (*User, error) { + if tx == nil || userID <= 0 { + return nil, errors.New("invalid user group state args") + } + user := &User{} + if err := lockForUpdate(tx).Where("id = ?", userID).First(user).Error; err != nil { + return nil, err + } + if user.GroupExpiresAt == 0 || user.GroupExpiresAt > now { + return user, nil + } + restoredGroup := strings.TrimSpace(user.GroupRestore) + if restoredGroup == "" { + restoredGroup = "Free" + } + if err := tx.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{ + "group": restoredGroup, + "group_restore": "", + "group_expires_at": 0, + }).Error; err != nil { + return nil, err + } + user.Group = restoredGroup + user.GroupRestore = "" + user.GroupExpiresAt = 0 + return user, nil +} + +func applyUserGroupEntitlementTx(tx *gorm.DB, userID int, targetGroup string, durationMinutes int64, now int64) (int64, error) { + targetGroup = strings.TrimSpace(targetGroup) + if targetGroup == "" || len(targetGroup) > 64 { + return 0, errors.New("invalid target group") + } + if durationMinutes < 0 || durationMinutes > MaxRedemptionGroupDurationMinutes { + return 0, errors.New("invalid group entitlement duration") + } + user, err := loadUserGroupStateForUpdateTx(tx, userID, now) + if err != nil { + return 0, err + } + if durationMinutes == 0 { + if err := tx.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{ + "group": targetGroup, + "group_restore": "", + "group_expires_at": 0, + }).Error; err != nil { + return 0, err + } + return 0, nil + } + + restoreGroup := strings.TrimSpace(user.Group) + expiresFrom := now + if user.GroupExpiresAt > now { + restoreGroup = strings.TrimSpace(user.GroupRestore) + if user.Group == targetGroup { + expiresFrom = user.GroupExpiresAt + } + } + if restoreGroup == "" { + restoreGroup = "Free" + } + durationSeconds := durationMinutes * 60 + if expiresFrom > math.MaxInt64-durationSeconds { + return 0, errors.New("group entitlement expiry overflow") + } + expiresAt := expiresFrom + durationSeconds + if err := tx.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{ + "group": targetGroup, + "group_restore": restoreGroup, + "group_expires_at": expiresAt, + }).Error; err != nil { + return 0, err + } + return expiresAt, nil +} + func ResolveExpiredUserGroup(user *User) error { if user == nil || user.GroupExpiresAt == 0 || user.GroupExpiresAt > common.GetTimestamp() { return nil @@ -28,23 +109,12 @@ func ResolveExpiredUserGroupByID(userID int) (string, error) { } group := "" err := DB.Transaction(func(tx *gorm.DB) error { - user := &User{} - if err := lockForUpdate(tx).Where("id = ?", userID).First(user).Error; err != nil { + user, err := loadUserGroupStateForUpdateTx(tx, userID, common.GetTimestamp()) + if err != nil { return err } - if user.GroupExpiresAt == 0 || user.GroupExpiresAt > common.GetTimestamp() { - group = user.Group - return nil - } - group = user.GroupRestore - if group == "" { - group = "Free" - } - return tx.Model(&User{}).Where("id = ?", userID).Updates(map[string]interface{}{ - "group": group, - "group_restore": "", - "group_expires_at": 0, - }).Error + group = user.Group + return nil }) if err != nil { return "", err diff --git a/web/default/src/components/data-table/core/data-table-view.tsx b/web/default/src/components/data-table/core/data-table-view.tsx index eae1c98..961b196 100644 --- a/web/default/src/components/data-table/core/data-table-view.tsx +++ b/web/default/src/components/data-table/core/data-table-view.tsx @@ -159,7 +159,7 @@ function SplitHeaderTableView({ diff --git a/web/default/src/features/redemption-codes/components/redemptions-mobile-list.tsx b/web/default/src/features/redemption-codes/components/redemptions-mobile-list.tsx index 1a6349d..4118d33 100644 --- a/web/default/src/features/redemption-codes/components/redemptions-mobile-list.tsx +++ b/web/default/src/features/redemption-codes/components/redemptions-mobile-list.tsx @@ -162,9 +162,11 @@ export function RedemptionsMobileList(props: RedemptionsMobileListProps) {
- {t('Quota')} + {t('Benefit')} - {formatQuota(redemption.quota)} + {redemption.type === 'group' + ? redemption.group_name + : formatQuota(redemption.quota)}
diff --git a/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx b/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx index ac56539..8fb02cb 100644 --- a/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx +++ b/web/default/src/features/redemption-codes/components/redemptions-mutate-drawer.tsx @@ -1,4 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod' +import { useQuery } from '@tanstack/react-query' import { type FormEvent, useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { useTranslation } from 'react-i18next' @@ -23,6 +24,15 @@ import { FormMessage, } from '@/components/ui/form' import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { getGroups } from '@/features/users/api' import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency' import { formatQuota, parseQuotaFromDollars } from '@/lib/format' @@ -54,6 +64,14 @@ export function RedemptionsMutateDrawer({ const { triggerRefresh } = useRedemptions() const [isSubmitting, setIsSubmitting] = useState(false) + const { data: groupsData } = useQuery({ + queryKey: ['groups'], + queryFn: getGroups, + enabled: open, + staleTime: 5 * 60 * 1000, + }) + const groups = groupsData?.data ?? [] + const form = useForm({ resolver: zodResolver(getRedemptionFormSchema(t)), defaultValues: REDEMPTION_FORM_DEFAULT_VALUES, @@ -95,9 +113,10 @@ export function RedemptionsMutateDrawer({ const handleSubmit = (event: FormEvent) => { if (!isUpdate && !form.getValues('name')?.trim()) { - const name = formatQuota( - parseQuotaFromDollars(form.getValues('quota_dollars')) - ) + const name = + form.getValues('type') === 'group' + ? form.getValues('group_name').slice(0, 20) + : formatQuota(parseQuotaFromDollars(form.getValues('quota_dollars'))) form.setValue('name', name, { shouldValidate: true }) } void form.handleSubmit(onSubmit)(event) @@ -106,6 +125,7 @@ export function RedemptionsMutateDrawer({ const { meta: currencyMeta } = getCurrencyDisplay() const currencyLabel = getCurrencyLabel() const tokensOnly = currencyMeta.kind === 'tokens' + const benefitType = form.watch('type') return ( @@ -124,20 +144,65 @@ export function RedemptionsMutateDrawer({ onSubmit={handleSubmit} className='space-y-4' > -
- ( - - {t('Name')} - - - - - - )} - /> + ( + + {t('Name')} + + + + + + )} + /> + + ( + + {t('Benefit type')} + +
+ + +
+
+ + {t( + 'Choose whether the code grants quota or a group entitlement.' + )} + + +
+ )} + /> + + {benefitType === 'quota' ? ( )} /> -
+ ) : ( +
+ ( + + {t('Target group')} + + + + )} + /> + ( + + {t('Duration (days)')} + + + field.onChange(Number(event.target.value) || 0) + } + /> + + + {t('0 means permanent entitlement')} + + + + )} + /> +
+ )} { + if (data.type === 'quota' && data.quota_dollars <= 0) { + context.addIssue({ + code: 'custom', + path: ['quota_dollars'], + message: t('Quota must be a positive number'), + }) + } + if (data.type === 'group' && !data.group_name.trim()) { + context.addIssue({ + code: 'custom', + path: ['group_name'], + message: t('Group is required'), + }) + } + }) } export type RedemptionFormValues = { name: string + type: 'quota' | 'group' quota_dollars: number + group_name: string + group_duration_days: number expired_time?: Date count?: number } export const REDEMPTION_FORM_DEFAULT_VALUES: RedemptionFormValues = { name: '', + type: 'quota', quota_dollars: 10, + group_name: '', + group_duration_days: 0, expired_time: undefined, count: 1, } @@ -45,8 +75,12 @@ export function transformFormDataToPayload( ): RedemptionFormData { return { name: data.name, - type: 'quota', - quota: parseQuotaFromDollars(data.quota_dollars), + type: data.type, + quota: + data.type === 'quota' ? parseQuotaFromDollars(data.quota_dollars) : 0, + group_name: data.type === 'group' ? data.group_name.trim() : '', + group_duration_minutes: + data.type === 'group' ? data.group_duration_days * 24 * 60 : 0, expired_time: data.expired_time ? Math.floor(data.expired_time.getTime() / 1000) : 0, @@ -59,7 +93,13 @@ export function transformRedemptionToFormDefaults( ): RedemptionFormValues { return { name: redemption.name, + type: redemption.type, quota_dollars: quotaUnitsToDollars(redemption.quota), + group_name: redemption.group_name, + group_duration_days: + redemption.group_duration_minutes > 0 + ? Math.ceil(redemption.group_duration_minutes / (24 * 60)) + : 0, expired_time: redemption.expired_time > 0 ? new Date(redemption.expired_time * 1000) diff --git a/web/default/src/features/redemption-codes/types.ts b/web/default/src/features/redemption-codes/types.ts index fa5c896..25acb00 100644 --- a/web/default/src/features/redemption-codes/types.ts +++ b/web/default/src/features/redemption-codes/types.ts @@ -77,7 +77,9 @@ export interface RedemptionFormData { id?: number name: string quota: number - type: 'quota' + type: 'quota' | 'group' + group_name?: string + group_duration_minutes?: number expired_time: number count?: number // Only for create status?: number // Only for status update diff --git a/web/default/src/features/wallet/hooks/use-redemption.ts b/web/default/src/features/wallet/hooks/use-redemption.ts index d6eafaa..c58fe2d 100644 --- a/web/default/src/features/wallet/hooks/use-redemption.ts +++ b/web/default/src/features/wallet/hooks/use-redemption.ts @@ -49,6 +49,12 @@ export function useRedemption() { quota: formatQuota(response.data), }) ) + } else if (response.data.type === 'group') { + toast.success( + i18next.t('Group entitlement activated: {{group}}', { + group: response.data.group_name, + }) + ) } else { toast.success( i18next.t('Redemption successful! Added: {{quota}}', { diff --git a/web/default/src/features/wallet/types.ts b/web/default/src/features/wallet/types.ts index a471aed..d3deda7 100644 --- a/web/default/src/features/wallet/types.ts +++ b/web/default/src/features/wallet/types.ts @@ -34,8 +34,10 @@ export interface ApiResponse { */ export type TopupInfoResponse = ApiResponse export interface RedemptionResult { - type: 'quota' + type: 'quota' | 'group' quota: number + group_name?: string + group_expires_at?: number } export type RedemptionResponse = ApiResponse diff --git a/web/default/src/i18n/locales/_reports/_sync-report.json b/web/default/src/i18n/locales/_reports/_sync-report.json index 10fdde3..4c0b730 100644 --- a/web/default/src/i18n/locales/_reports/_sync-report.json +++ b/web/default/src/i18n/locales/_reports/_sync-report.json @@ -11,7 +11,7 @@ "file": "fr.json", "missingCount": 0, "extrasCount": 0, - "untranslatedCount": 5 + "untranslatedCount": 4 }, "ja": { "file": "ja.json", @@ -23,13 +23,13 @@ "file": "ru.json", "missingCount": 0, "extrasCount": 0, - "untranslatedCount": 30 + "untranslatedCount": 23 }, "vi": { "file": "vi.json", "missingCount": 0, "extrasCount": 0, - "untranslatedCount": 5 + "untranslatedCount": 4 }, "zh-TW": { "file": "zh-TW.json", diff --git a/web/default/src/i18n/locales/_reports/fr.untranslated.json b/web/default/src/i18n/locales/_reports/fr.untranslated.json index 9814d38..4630c78 100644 --- a/web/default/src/i18n/locales/_reports/fr.untranslated.json +++ b/web/default/src/i18n/locales/_reports/fr.untranslated.json @@ -1,5 +1,4 @@ { - "Choose whether the code grants quota or a group entitlement.": "Choose whether the code grants quota or a group entitlement.", "Failed to save redemption code": "Failed to save redemption code", "Limits are based on the current group policy": "Limits are based on the current group policy", "Manage group limits and automatic upgrades": "Manage group limits and automatic upgrades", diff --git a/web/default/src/i18n/locales/_reports/ru.untranslated.json b/web/default/src/i18n/locales/_reports/ru.untranslated.json index a859aac..4946c5d 100644 --- a/web/default/src/i18n/locales/_reports/ru.untranslated.json +++ b/web/default/src/i18n/locales/_reports/ru.untranslated.json @@ -2,15 +2,10 @@ "24 hours": "24 hours", "Add group": "Add group", "Benefit": "Benefit", - "Benefit type": "Benefit type", - "Choose whether the code grants quota or a group entitlement.": "Choose whether the code grants quota or a group entitlement.", "Code expiration time": "Code expiration time", "Current hour": "Current hour", - "Duration (days)": "Duration (days)", "Duration must be at least one day": "Duration must be at least one day", "Failed to save redemption code": "Failed to save redemption code", - "Group entitlement": "Group entitlement", - "Group entitlement activated: {{group}}": "Group entitlement activated: {{group}}", "Group name": "Group name", "Group Settings": "Group Settings", "Group settings saved": "Group settings saved", @@ -22,10 +17,8 @@ "Maximum successful requests": "Maximum successful requests", "New group": "New group", "Period (minutes)": "Period (minutes)", - "Permanent entitlement": "Permanent entitlement", "Recharge threshold": "Recharge threshold", "Select a target group": "Select a target group", - "Target group": "Target group", "Terms of Service": "Terms of Service", "Use 0 for unlimited. TPM is measured over the latest minute.": "Use 0 for unlimited. TPM is measured over the latest minute.", "User group": "User group" diff --git a/web/default/src/i18n/locales/_reports/vi.untranslated.json b/web/default/src/i18n/locales/_reports/vi.untranslated.json index 9814d38..4630c78 100644 --- a/web/default/src/i18n/locales/_reports/vi.untranslated.json +++ b/web/default/src/i18n/locales/_reports/vi.untranslated.json @@ -1,5 +1,4 @@ { - "Choose whether the code grants quota or a group entitlement.": "Choose whether the code grants quota or a group entitlement.", "Failed to save redemption code": "Failed to save redemption code", "Limits are based on the current group policy": "Limits are based on the current group policy", "Manage group limits and automatic upgrades": "Manage group limits and automatic upgrades", diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 2a5ceac..c3a1cdc 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -89,6 +89,7 @@ "+{{count}} more": "+{{count}} more", "| Based on": "| Based on", "0 means data is kept permanently": "0 means data is kept permanently", + "0 means permanent entitlement": "0 means permanent entitlement", "0 means unlimited": "0 means unlimited", "1 Day": "1 Day", "1 day ago": "1 day ago", @@ -1230,6 +1231,7 @@ "Create": "Create", "Create a copy of:": "Create a copy of:", "Create a key for your app or service": "Create a key for your app or service", + "Create a new API key after upgrading your subscription.": "Create a new API key after upgrading your subscription.", "Create a new user group to configure ratio overrides for.": "Create a new user group to configure ratio overrides for.", "Create account": "Create account", "Create an account": "Create an account", @@ -1593,6 +1595,7 @@ "Duration": "Duration", "Duration (days)": "Duration (days)", "Duration (hours)": "Duration (hours)", + "Duration cannot exceed 3650 days": "Duration cannot exceed 3650 days", "Duration must be at least one day": "Duration must be at least one day", "Duration Settings": "Duration Settings", "Duration Unit": "Duration Unit", @@ -3122,6 +3125,7 @@ "Next reset": "Next reset", "No": "No", "No About Content Set": "No About Content Set", + "No access": "No access", "No Active": "No Active", "No active system tasks.": "No active system tasks.", "No additional type-specific settings for this channel type.": "No additional type-specific settings for this channel type.", @@ -5668,8 +5672,6 @@ "Zero retention": "Zero retention", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom", - "No access": "No access", - "Create a new API key after upgrading your subscription.": "Create a new API key after upgrading your subscription." + "Zoom": "Zoom" } } diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 1660aaf..04003a3 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -89,6 +89,7 @@ "+{{count}} more": "+{{count}} de plus", "| Based on": "| Basé sur", "0 means data is kept permanently": "0 signifie que les données sont conservées indéfiniment", + "0 means permanent entitlement": "0 correspond à un droit permanent", "0 means unlimited": "0 signifie illimité", "1 Day": "1 jour", "1 day ago": "Il y a 1 jour", @@ -676,7 +677,7 @@ "Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed": "Mises à jour par lot des modèles en amont appliquées : {{channels}} canaux, {{added}} ajoutés, {{removed}} supprimés, {{fails}} échoués", "Below 10%": "Moins de 10 %", "Benefit": "Benefit", - "Benefit type": "Benefit type", + "Benefit type": "Type d’avantage", "Best for single-tenant deployments. Pricing and billing options stay hidden.": "Idéal pour les déploiements mono-utilisateur. Les options de tarification et de facturation restent masquées.", "Best TTFT": "Meilleur TTFT", "Billable input tokens": "Tokens d’entrée facturables", @@ -902,7 +903,7 @@ "Choose the bundle type and define the items inside it.": "Choisissez le type de bundle et définissez les éléments qu'il contient.", "Choose the default charts, range, and time granularity for model analytics.": "Choisissez les graphiques, la plage et la granularité temporelle par défaut pour l'analyse des modèles.", "Choose where to fetch upstream metadata.": "Choisissez où récupérer les métadonnées amont.", - "Choose whether the code grants quota or a group entitlement.": "Choose whether the code grants quota or a group entitlement.", + "Choose whether the code grants quota or a group entitlement.": "Choisissez si le code accorde un quota ou un droit de groupe.", "Choose which charts are selected by default when opening model analytics.": "Choisissez les graphiques sélectionnés par défaut à l'ouverture de l'analyse des modèles.", "Clamped to": "Limité à", "Classic (Legacy Frontend)": "Classique (Ancien frontend)", @@ -1230,6 +1231,7 @@ "Create": "Créer", "Create a copy of:": "Créer une copie de :", "Create a key for your app or service": "Créer une clé pour votre application ou service", + "Create a new API key after upgrading your subscription.": "Après la mise à niveau de votre abonnement, créez une nouvelle clé API.", "Create a new user group to configure ratio overrides for.": "Créer un nouveau groupe d'utilisateurs pour configurer les remplacements de ratio.", "Create account": "Créer un compte", "Create an account": "Créer un compte", @@ -1591,8 +1593,9 @@ "Duplicate source model mappings are not allowed": "Les mappages de modèles source en double ne sont pas autorisés", "Duplicate source model(s): {{models}}": "Modèle(s) source en double : {{models}}", "Duration": "Durée", - "Duration (days)": "Duration (days)", + "Duration (days)": "Durée (jours)", "Duration (hours)": "Durée (heures)", + "Duration cannot exceed 3650 days": "La durée ne peut pas dépasser 3650 jours", "Duration must be at least one day": "Duration must be at least one day", "Duration Settings": "Paramètres de durée", "Duration Unit": "Unité de durée", @@ -2328,8 +2331,8 @@ "Group deleted. Click \"Save Settings\" to apply.": "Groupe supprimé. Cliquez sur \"Enregistrer les paramètres\" pour appliquer.", "Group description": "Description du groupe", "Group details": "Détails du groupe", - "Group entitlement": "Group entitlement", - "Group entitlement activated: {{group}}": "Group entitlement activated: {{group}}", + "Group entitlement": "Droit de groupe", + "Group entitlement activated: {{group}}": "Droit de groupe activé : {{group}}", "Group identifier": "Identifiant du groupe", "Group is required": "Le groupe est requis", "Group name": "Group name", @@ -3122,6 +3125,7 @@ "Next reset": "Prochaine réinitialisation", "No": "Non", "No About Content Set": "Aucun contenu « À propos » défini", + "No access": "Aucun accès", "No Active": "Aucun actif", "No active system tasks.": "Aucune tâche système active.", "No additional type-specific settings for this channel type.": "Aucun paramètre supplémentaire spécifique au type pour ce type de canal.", @@ -3653,7 +3657,7 @@ "Periodically check for upstream model changes": "Vérifier périodiquement les changements de modèles en amont", "Periodically send ping frames to keep streaming connections active.": "Envoyer périodiquement des trames ping pour maintenir les connexions de streaming actives.", "Permanent": "Permanent", - "Permanent entitlement": "Permanent entitlement", + "Permanent entitlement": "Droit permanent", "Permanently delete your account and all data": "Supprimer définitivement votre compte et toutes les données", "Permit Passkey registration on non-HTTPS origins (only recommended for development)": "Autoriser l'enregistrement de Passkey sur des origines non-HTTPS (recommandé uniquement pour le développement)", "Perplexity": "Perplexity", @@ -4813,7 +4817,7 @@ "Target Endpoint": "Point cible", "Target Field": "Champ cible", "Target Field Path": "Chemin du champ cible", - "Target group": "Target group", + "Target group": "Groupe cible", "Target Header": "En-tête cible", "Target Path (optional)": "Chemin cible (optionnel)", "Target User": "Utilisateur cible", @@ -5668,8 +5672,6 @@ "Zero retention": "Aucune rétention", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom", - "No access": "Aucun accès", - "Create a new API key after upgrading your subscription.": "Après la mise à niveau de votre abonnement, créez une nouvelle clé API." + "Zoom": "Zoom" } } diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 87bed24..9527b05 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -89,6 +89,7 @@ "+{{count}} more": "他 {{count}} 件", "| Based on": "| に基づく", "0 means data is kept permanently": "0 はデータを永続的に保持することを意味します", + "0 means permanent entitlement": "0 を指定すると永久権限になります", "0 means unlimited": "0は無制限を意味します", "1 Day": "1日", "1 day ago": "1日前", @@ -902,7 +903,7 @@ "Choose the bundle type and define the items inside it.": "バンドルタイプを選択し、その中のアイテムを定義してください。", "Choose the default charts, range, and time granularity for model analytics.": "モデル分析のデフォルトチャート、範囲、時間粒度を選択します。", "Choose where to fetch upstream metadata.": "アップストリームのメタデータをどこからフェッチするかを選択してください。", - "Choose whether the code grants quota or a group entitlement.": "コードでクォータまたはグループ権限を付与します。", + "Choose whether the code grants quota or a group entitlement.": "コードでクォータまたはグループ権限を付与するか選択します。", "Choose which charts are selected by default when opening model analytics.": "モデル分析を開いたときにデフォルトで選択されるチャートを選択します。", "Clamped to": "制限後の値", "Classic (Legacy Frontend)": "クラシック(旧フロントエンド)", @@ -1230,6 +1231,7 @@ "Create": "新規作成", "Create a copy of:": "コピーを作成:", "Create a key for your app or service": "アプリまたはサービス用のキーを作成", + "Create a new API key after upgrading your subscription.": "サブスクリプションのアップグレード後、新しい API キーを作成してください。", "Create a new user group to configure ratio overrides for.": "レートの上書きを設定するための新しいユーザーグループを作成します。", "Create account": "アカウントを作成", "Create an account": "アカウントを作成", @@ -1593,6 +1595,7 @@ "Duration": "所要時間", "Duration (days)": "期間(日)", "Duration (hours)": "期間(時間)", + "Duration cannot exceed 3650 days": "期間は 3650 日を超えられません", "Duration must be at least one day": "期間は1日以上にしてください", "Duration Settings": "有効期間設定", "Duration Unit": "期間単位", @@ -2331,7 +2334,7 @@ "Group entitlement": "グループ権限", "Group entitlement activated: {{group}}": "グループ権限を有効化しました:{{group}}", "Group identifier": "グループ識別子", - "Group is required": "グループは必須です", + "Group is required": "グループを選択してください", "Group name": "グループ名", "Group Name": "グループ名", "Group name cannot be changed when editing.": "編集時はグループ名を変更できません。", @@ -3122,6 +3125,7 @@ "Next reset": "次のリセット", "No": "いいえ", "No About Content Set": "概要コンテンツが設定されていません", + "No access": "権限なし", "No Active": "アクティブなし", "No active system tasks.": "進行中のシステムタスクはありません。", "No additional type-specific settings for this channel type.": "このチャネルタイプには、追加のタイプ固有の設定はありません。", @@ -5668,8 +5672,6 @@ "Zero retention": "データ保持なし", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V 4", - "Zoom": "ズーム", - "No access": "権限なし", - "Create a new API key after upgrading your subscription.": "サブスクリプションのアップグレード後、新しい API キーを作成してください。" + "Zoom": "ズーム" } } diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index afe238e..9282042 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -89,6 +89,7 @@ "+{{count}} more": "ещё {{count}}", "| Based on": "| На основе", "0 means data is kept permanently": "0 означает, что данные хранятся постоянно", + "0 means permanent entitlement": "0 означает бессрочное право", "0 means unlimited": "0 означает без ограничений", "1 Day": "1 день", "1 day ago": "1 день назад", @@ -676,7 +677,7 @@ "Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed": "Пакетное обновление моделей: {{channels}} каналов, {{added}} добавлено, {{removed}} удалено, {{fails}} ошибок", "Below 10%": "Ниже 10%", "Benefit": "Benefit", - "Benefit type": "Benefit type", + "Benefit type": "Тип преимущества", "Best for single-tenant deployments. Pricing and billing options stay hidden.": "Лучший вариант для однопользовательских развёртываний. Опции ценообразования и биллинга будут скрыты.", "Best TTFT": "Лучший TTFT", "Billable input tokens": "Оплачиваемые входные токены", @@ -902,7 +903,7 @@ "Choose the bundle type and define the items inside it.": "Выберите тип пакета и определите элементы внутри него.", "Choose the default charts, range, and time granularity for model analytics.": "Выберите графики, диапазон и временную детализацию по умолчанию для аналитики моделей.", "Choose where to fetch upstream metadata.": "Выберите, откуда получать метаданные вышестоящего источника.", - "Choose whether the code grants quota or a group entitlement.": "Choose whether the code grants quota or a group entitlement.", + "Choose whether the code grants quota or a group entitlement.": "Выберите, предоставляет ли код квоту или право группы.", "Choose which charts are selected by default when opening model analytics.": "Выберите графики, которые будут выбраны по умолчанию при открытии аналитики моделей.", "Clamped to": "Ограничено до", "Classic (Legacy Frontend)": "Классический (Старый интерфейс)", @@ -1230,6 +1231,7 @@ "Create": "Создать", "Create a copy of:": "Создать копию:", "Create a key for your app or service": "Создайте ключ для приложения или сервиса", + "Create a new API key after upgrading your subscription.": "После повышения тарифа создайте новый API-ключ.", "Create a new user group to configure ratio overrides for.": "Создайте новую группу пользователей для настройки переопределений соотношений.", "Create account": "Создать аккаунт", "Create an account": "Создать аккаунт", @@ -1591,8 +1593,9 @@ "Duplicate source model mappings are not allowed": "Повторяющиеся сопоставления исходных моделей не допускаются", "Duplicate source model(s): {{models}}": "Повторяющиеся исходные модели: {{models}}", "Duration": "Длительность", - "Duration (days)": "Duration (days)", + "Duration (days)": "Продолжительность (дни)", "Duration (hours)": "Длительность (часы)", + "Duration cannot exceed 3650 days": "Продолжительность не может превышать 3650 дней", "Duration must be at least one day": "Duration must be at least one day", "Duration Settings": "Настройки срока действия", "Duration Unit": "Единица срока", @@ -2328,10 +2331,10 @@ "Group deleted. Click \"Save Settings\" to apply.": "Группа удалена. Нажмите \"Сохранить настройки\", чтобы применить.", "Group description": "Описание группы", "Group details": "Детали группы", - "Group entitlement": "Group entitlement", - "Group entitlement activated: {{group}}": "Group entitlement activated: {{group}}", + "Group entitlement": "Право группы", + "Group entitlement activated: {{group}}": "Право группы активировано: {{group}}", "Group identifier": "Идентификатор группы", - "Group is required": "Группа обязательна", + "Group is required": "Необходимо выбрать группу", "Group name": "Group name", "Group Name": "Имя группы", "Group name cannot be changed when editing.": "Имя группы нельзя изменить при редактировании.", @@ -3122,6 +3125,7 @@ "Next reset": "Следующий сброс", "No": "Нет", "No About Content Set": "Содержимое раздела \"О нас\" не установлено", + "No access": "Нет доступа", "No Active": "Нет активных", "No active system tasks.": "Нет активных системных задач.", "No additional type-specific settings for this channel type.": "Нет дополнительных настроек, специфичных для этого типа канала.", @@ -3653,7 +3657,7 @@ "Periodically check for upstream model changes": "Периодически проверять изменения моделей провайдера", "Periodically send ping frames to keep streaming connections active.": "Периодически отправлять пинг-кадры для поддержания активности потоковых соединений.", "Permanent": "Бессрочно", - "Permanent entitlement": "Permanent entitlement", + "Permanent entitlement": "Бессрочное право", "Permanently delete your account and all data": "Безвозвратно удалить ваш аккаунт и все данные", "Permit Passkey registration on non-HTTPS origins (only recommended for development)": "Разрешить регистрацию Passkey на не-HTTPS источниках (рекомендуется только для разработки)", "Perplexity": "Perplexity", @@ -4813,7 +4817,7 @@ "Target Endpoint": "Целевая точка", "Target Field": "Целевое поле", "Target Field Path": "Путь целевого поля", - "Target group": "Target group", + "Target group": "Целевая группа", "Target Header": "Целевой заголовок", "Target Path (optional)": "Целевой путь (необязательно)", "Target User": "Целевой пользователь", @@ -5668,8 +5672,6 @@ "Zero retention": "Без хранения данных", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom", - "No access": "Нет доступа", - "Create a new API key after upgrading your subscription.": "После повышения тарифа создайте новый API-ключ." + "Zoom": "Zoom" } } diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 8b14b04..3ca5c03 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -89,6 +89,7 @@ "+{{count}} more": "thêm {{count}} mục", "| Based on": "| Dựa trên", "0 means data is kept permanently": "0 nghĩa là dữ liệu được giữ vĩnh viễn", + "0 means permanent entitlement": "Nhập 0 để cấp quyền vĩnh viễn", "0 means unlimited": "0 có nghĩa là không giới hạn", "1 Day": "1 ngày", "1 day ago": "1 ngày trước", @@ -676,7 +677,7 @@ "Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed": "Đã áp dụng cập nhật hàng loạt mô hình upstream: {{channels}} kênh, {{added}} đã thêm, {{removed}} đã xóa, {{fails}} thất bại", "Below 10%": "Dưới 10%", "Benefit": "Benefit", - "Benefit type": "Benefit type", + "Benefit type": "Loại quyền lợi", "Best for single-tenant deployments. Pricing and billing options stay hidden.": "Phù hợp nhất cho triển khai đơn người dùng. Các tùy chọn giá và thanh toán sẽ được ẩn.", "Best TTFT": "TTFT tốt nhất", "Billable input tokens": "Token đầu vào tính phí", @@ -902,7 +903,7 @@ "Choose the bundle type and define the items inside it.": "Chọn loại gói và định nghĩa các mục bên trong nó.", "Choose the default charts, range, and time granularity for model analytics.": "Chọn biểu đồ, khoảng thời gian và độ chi tiết thời gian mặc định cho phân tích mô hình.", "Choose where to fetch upstream metadata.": "Chọn nơi để tìm nạp siêu dữ liệu thượng nguồn.", - "Choose whether the code grants quota or a group entitlement.": "Choose whether the code grants quota or a group entitlement.", + "Choose whether the code grants quota or a group entitlement.": "Chọn mã cấp hạn mức hay quyền theo nhóm.", "Choose which charts are selected by default when opening model analytics.": "Chọn biểu đồ được chọn mặc định khi mở phân tích mô hình.", "Clamped to": "Giới hạn thành", "Classic (Legacy Frontend)": "Cổ điển (Frontend cũ)", @@ -1230,6 +1231,7 @@ "Create": "Tạo", "Create a copy of:": "Tạo bản sao của:", "Create a key for your app or service": "Tạo khóa cho ứng dụng hoặc dịch vụ của bạn", + "Create a new API key after upgrading your subscription.": "Sau khi nâng cấp gói đăng ký, hãy tạo khóa API mới.", "Create a new user group to configure ratio overrides for.": "Tạo một nhóm người dùng mới để cấu hình ghi đè tỷ lệ.", "Create account": "Tạo tài khoản", "Create an account": "Tạo tài khoản", @@ -1591,8 +1593,9 @@ "Duplicate source model mappings are not allowed": "Không cho phép ánh xạ mô hình nguồn trùng lặp", "Duplicate source model(s): {{models}}": "Mô hình nguồn trùng lặp: {{models}}", "Duration": "Thời lượng", - "Duration (days)": "Duration (days)", + "Duration (days)": "Thời hạn (ngày)", "Duration (hours)": "Thời lượng (giờ)", + "Duration cannot exceed 3650 days": "Thời hạn không được vượt quá 3650 ngày", "Duration must be at least one day": "Duration must be at least one day", "Duration Settings": "Cài đặt thời lượng", "Duration Unit": "Đơn vị thời lượng", @@ -2328,10 +2331,10 @@ "Group deleted. Click \"Save Settings\" to apply.": "Nhóm đã bị xóa. Nhấp vào \"Lưu Cài đặt\" để áp dụng.", "Group description": "Mô tả nhóm", "Group details": "Chi tiết nhóm", - "Group entitlement": "Group entitlement", - "Group entitlement activated: {{group}}": "Group entitlement activated: {{group}}", + "Group entitlement": "Quyền theo nhóm", + "Group entitlement activated: {{group}}": "Đã kích hoạt quyền theo nhóm: {{group}}", "Group identifier": "Định danh nhóm", - "Group is required": "Yêu cầu nhóm", + "Group is required": "Vui lòng chọn nhóm", "Group name": "Group name", "Group Name": "Tên Nhóm", "Group name cannot be changed when editing.": "Tên nhóm không thể thay đổi khi chỉnh sửa.", @@ -3122,6 +3125,7 @@ "Next reset": "Lần đặt lại tiếp theo", "No": "Không", "No About Content Set": "Chưa đặt nội dung Giới thiệu", + "No access": "Không có quyền", "No Active": "Không hoạt động", "No active system tasks.": "Không có tác vụ hệ thống đang hoạt động.", "No additional type-specific settings for this channel type.": "Không có cài đặt bổ sung cụ thể theo loại cho loại kênh này.", @@ -3653,7 +3657,7 @@ "Periodically check for upstream model changes": "Kiểm tra định kỳ các thay đổi mô hình nguồn", "Periodically send ping frames to keep streaming connections active.": "Định kỳ gửi các khung ping để duy trì các kết nối truyền phát hoạt động.", "Permanent": "Vĩnh viễn", - "Permanent entitlement": "Permanent entitlement", + "Permanent entitlement": "Quyền vĩnh viễn", "Permanently delete your account and all data": "Xóa vĩnh viễn tài khoản của bạn và tất cả dữ liệu", "Permit Passkey registration on non-HTTPS origins (only recommended for development)": "Cho phép đăng ký Passkey trên các nguồn gốc không phải HTTPS (chỉ khuyến nghị cho mục đích phát triển)", "Perplexity": "Sự bối rối", @@ -4813,7 +4817,7 @@ "Target Endpoint": "Điểm đích", "Target Field": "Trường đích", "Target Field Path": "Đường dẫn trường đích", - "Target group": "Target group", + "Target group": "Nhóm đích", "Target Header": "Header đích", "Target Path (optional)": "Đường dẫn đích (tùy chọn)", "Target User": "Người dùng mục tiêu", @@ -5668,8 +5672,6 @@ "Zero retention": "Không lưu dữ liệu", "Zhipu": "Zhipu", "Zhipu V4": "Zhipu V4", - "Zoom": "Zoom", - "No access": "Không có quyền", - "Create a new API key after upgrading your subscription.": "Sau khi nâng cấp gói đăng ký, hãy tạo khóa API mới." + "Zoom": "Zoom" } } diff --git a/web/default/src/i18n/locales/zh-TW.json b/web/default/src/i18n/locales/zh-TW.json index 3209483..9573c5a 100644 --- a/web/default/src/i18n/locales/zh-TW.json +++ b/web/default/src/i18n/locales/zh-TW.json @@ -89,6 +89,7 @@ "+{{count}} more": "還有 {{count}} 項", "| Based on": "| 基於", "0 means data is kept permanently": "0 表示永久保留數據", + "0 means permanent entitlement": "填寫 0 表示永久權益", "0 means unlimited": "0 表示不限", "1 Day": "1 日", "1 day ago": "1 日前", @@ -676,7 +677,7 @@ "Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed": "已大量處理上游模型更新:渠道 {{channels}} 個,加入 {{added}} 個,刪除 {{removed}} 個,失敗 {{fails}} 個", "Below 10%": "低於 10%", "Benefit": "兑换权益", - "Benefit type": "兑换类型", + "Benefit type": "兌換類型", "Best for single-tenant deployments. Pricing and billing options stay hidden.": "適合單用戶部署。定價和收費選項將被隱藏。", "Best TTFT": "最優 TTFT", "Billable input tokens": "收費輸入 token", @@ -902,7 +903,7 @@ "Choose the bundle type and define the items inside it.": "選擇捆綁包類型並定義其中的項目。", "Choose the default charts, range, and time granularity for model analytics.": "選擇模型呼叫分析的預設圖表、範圍和時間粒度。", "Choose where to fetch upstream metadata.": "選擇從何處獲取上游元數據。", - "Choose whether the code grants quota or a group entitlement.": "选择兑换码发放额度或分组权益。", + "Choose whether the code grants quota or a group entitlement.": "選擇兌換碼發放額度或分組權益。", "Choose which charts are selected by default when opening model analytics.": "選擇打開模型呼叫分析時預設選中的圖表。", "Clamped to": "限制為", "Classic (Legacy Frontend)": "經典前端", @@ -1230,6 +1231,7 @@ "Create": "建立", "Create a copy of:": "建立副本:", "Create a key for your app or service": "為你的套用或服務建立金鑰", + "Create a new API key after upgrading your subscription.": "升級套餐後,請建立新的 API 金鑰再使用。", "Create a new user group to configure ratio overrides for.": "建立一個新的用戶分組來設定比例覆蓋。", "Create account": "建立用戶", "Create an account": "建立一個用戶", @@ -1591,8 +1593,9 @@ "Duplicate source model mappings are not allowed": "不允許重複的源模型映射", "Duplicate source model(s): {{models}}": "重複的源模型:{{models}}", "Duration": "耗時", - "Duration (days)": "持续时间(天)", + "Duration (days)": "持續時間(天)", "Duration (hours)": "時長 (小時)", + "Duration cannot exceed 3650 days": "持續時間不能超過 3650 天", "Duration must be at least one day": "持续时间至少为一天", "Duration Settings": "有效期設定", "Duration Unit": "有效期單位", @@ -2328,10 +2331,10 @@ "Group deleted. Click \"Save Settings\" to apply.": "組已刪除。點擊「儲存設定」以套用。", "Group description": "分組描述", "Group details": "分組詳情", - "Group entitlement": "分组权益", - "Group entitlement activated: {{group}}": "已激活分组权益:{{group}}", + "Group entitlement": "分組權益", + "Group entitlement activated: {{group}}": "已啟用分組權益:{{group}}", "Group identifier": "分組標識符", - "Group is required": "組是必需的", + "Group is required": "請選擇分組", "Group name": "分组名称", "Group Name": "分組名稱", "Group name cannot be changed when editing.": "編輯時無法更改組名稱。", @@ -3122,6 +3125,7 @@ "Next reset": "下一次重置", "No": "否", "No About Content Set": "未設定關於內容", + "No access": "無權限", "No Active": "無生效", "No active system tasks.": "暫無進行中的系統任務。", "No additional type-specific settings for this channel type.": "此渠道類型沒有額外的特定類型設定。", @@ -3653,7 +3657,7 @@ "Periodically check for upstream model changes": "定期檢查上游模型是否有變更", "Periodically send ping frames to keep streaming connections active.": "定期發送 ping 幀以保持串流連接處於活動狀態。", "Permanent": "永久", - "Permanent entitlement": "永久权益", + "Permanent entitlement": "永久權益", "Permanently delete your account and all data": "永久刪除您的用戶和所有數據", "Permit Passkey registration on non-HTTPS origins (only recommended for development)": "允許在非 HTTPS 源上註冊通行金鑰(僅建議用於開發)", "Perplexity": "Perplexity", @@ -4813,7 +4817,7 @@ "Target Endpoint": "目標端點", "Target Field": "目標欄位", "Target Field Path": "目標欄位路徑", - "Target group": "目标分组", + "Target group": "目標分組", "Target Header": "目標請求頭", "Target Path (optional)": "目標路徑(可選)", "Target User": "目標用戶", @@ -5668,8 +5672,6 @@ "Zero retention": "零數據保留", "Zhipu": "智譜", "Zhipu V4": "智譜 V4", - "Zoom": "縮放", - "No access": "無權限", - "Create a new API key after upgrading your subscription.": "升級套餐後,請建立新的 API 金鑰再使用。" + "Zoom": "縮放" } } diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 328c45c..6e098fe 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -89,6 +89,7 @@ "+{{count}} more": "还有 {{count}} 项", "| Based on": "| 基于", "0 means data is kept permanently": "0 表示永久保留数据", + "0 means permanent entitlement": "填写 0 表示永久权益", "0 means unlimited": "0 表示不限", "1 Day": "1 天", "1 day ago": "1 天前", @@ -1230,6 +1231,7 @@ "Create": "新建", "Create a copy of:": "创建副本:", "Create a key for your app or service": "为你的应用或服务创建密钥", + "Create a new API key after upgrading your subscription.": "升级套餐后请创建新密钥使用。", "Create a new user group to configure ratio overrides for.": "创建一个新的用户分组来配置比例覆盖。", "Create account": "创建账户", "Create an account": "创建一个账户", @@ -1593,6 +1595,7 @@ "Duration": "耗时", "Duration (days)": "持续时间(天)", "Duration (hours)": "时长 (小时)", + "Duration cannot exceed 3650 days": "持续时间不能超过 3650 天", "Duration must be at least one day": "持续时间至少为一天", "Duration Settings": "有效期设置", "Duration Unit": "有效期单位", @@ -2331,7 +2334,7 @@ "Group entitlement": "分组权益", "Group entitlement activated: {{group}}": "已激活分组权益:{{group}}", "Group identifier": "分组标识符", - "Group is required": "组是必需的", + "Group is required": "请选择分组", "Group name": "分组名称", "Group Name": "分组名称", "Group name cannot be changed when editing.": "编辑时无法更改组名称。", @@ -3122,6 +3125,7 @@ "Next reset": "下一次重置", "No": "否", "No About Content Set": "未设置关于内容", + "No access": "无权限", "No Active": "无生效", "No active system tasks.": "暂无进行中的系统任务。", "No additional type-specific settings for this channel type.": "此渠道类型没有额外的特定类型设置。", @@ -5668,8 +5672,6 @@ "Zero retention": "零数据保留", "Zhipu": "智谱", "Zhipu V4": "智谱 V4", - "Zoom": "缩放", - "No access": "无权限", - "Create a new API key after upgrading your subscription.": "升级套餐后请创建新密钥使用。" + "Zoom": "缩放" } }