mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-19 07:47:28 +00:00
✨ feat: Add subscription upgrade group with auto downgrade
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/QuantumNous/new-api/common"
|
"github.com/QuantumNous/new-api/common"
|
||||||
"github.com/QuantumNous/new-api/model"
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -135,6 +136,13 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
|
|||||||
common.ApiErrorMsg(c, "总额度不能为负数")
|
common.ApiErrorMsg(c, "总额度不能为负数")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
req.Plan.UpgradeGroup = strings.TrimSpace(req.Plan.UpgradeGroup)
|
||||||
|
if req.Plan.UpgradeGroup != "" {
|
||||||
|
if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.UpgradeGroup]; !ok {
|
||||||
|
common.ApiErrorMsg(c, "升级分组不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
|
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
|
||||||
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
|
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
|
||||||
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
|
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
|
||||||
@@ -183,6 +191,13 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
|||||||
common.ApiErrorMsg(c, "总额度不能为负数")
|
common.ApiErrorMsg(c, "总额度不能为负数")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
req.Plan.UpgradeGroup = strings.TrimSpace(req.Plan.UpgradeGroup)
|
||||||
|
if req.Plan.UpgradeGroup != "" {
|
||||||
|
if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.UpgradeGroup]; !ok {
|
||||||
|
common.ApiErrorMsg(c, "升级分组不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
|
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
|
||||||
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
|
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
|
||||||
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
|
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
|
||||||
@@ -205,6 +220,7 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
|||||||
"creem_product_id": req.Plan.CreemProductId,
|
"creem_product_id": req.Plan.CreemProductId,
|
||||||
"max_purchase_per_user": req.Plan.MaxPurchasePerUser,
|
"max_purchase_per_user": req.Plan.MaxPurchasePerUser,
|
||||||
"total_amount": req.Plan.TotalAmount,
|
"total_amount": req.Plan.TotalAmount,
|
||||||
|
"upgrade_group": req.Plan.UpgradeGroup,
|
||||||
"updated_at": common.GetTimestamp(),
|
"updated_at": common.GetTimestamp(),
|
||||||
}
|
}
|
||||||
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
|
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
|
||||||
@@ -254,10 +270,15 @@ func AdminBindSubscription(c *gin.Context) {
|
|||||||
common.ApiErrorMsg(c, "参数错误")
|
common.ApiErrorMsg(c, "参数错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := model.AdminBindSubscription(req.UserId, req.PlanId, ""); err != nil {
|
msg, err := model.AdminBindSubscription(req.UserId, req.PlanId, "")
|
||||||
|
if err != nil {
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if msg != "" {
|
||||||
|
common.ApiSuccess(c, gin.H{"message": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
common.ApiSuccess(c, nil)
|
common.ApiSuccess(c, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,10 +314,15 @@ func AdminCreateUserSubscription(c *gin.Context) {
|
|||||||
common.ApiErrorMsg(c, "参数错误")
|
common.ApiErrorMsg(c, "参数错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := model.AdminBindSubscription(userId, req.PlanId, ""); err != nil {
|
msg, err := model.AdminBindSubscription(userId, req.PlanId, "")
|
||||||
|
if err != nil {
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if msg != "" {
|
||||||
|
common.ApiSuccess(c, gin.H{"message": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
common.ApiSuccess(c, nil)
|
common.ApiSuccess(c, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,10 +333,15 @@ func AdminInvalidateUserSubscription(c *gin.Context) {
|
|||||||
common.ApiErrorMsg(c, "无效的订阅ID")
|
common.ApiErrorMsg(c, "无效的订阅ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := model.AdminInvalidateUserSubscription(subId); err != nil {
|
msg, err := model.AdminInvalidateUserSubscription(subId)
|
||||||
|
if err != nil {
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if msg != "" {
|
||||||
|
common.ApiSuccess(c, gin.H{"message": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
common.ApiSuccess(c, nil)
|
common.ApiSuccess(c, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,9 +352,14 @@ func AdminDeleteUserSubscription(c *gin.Context) {
|
|||||||
common.ApiErrorMsg(c, "无效的订阅ID")
|
common.ApiErrorMsg(c, "无效的订阅ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := model.AdminDeleteUserSubscription(subId); err != nil {
|
msg, err := model.AdminDeleteUserSubscription(subId)
|
||||||
|
if err != nil {
|
||||||
common.ApiError(c, err)
|
common.ApiError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if msg != "" {
|
||||||
|
common.ApiSuccess(c, gin.H{"message": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
common.ApiSuccess(c, nil)
|
common.ApiSuccess(c, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,9 @@ type SubscriptionPlan struct {
|
|||||||
// Max purchases per user (0 = unlimited)
|
// Max purchases per user (0 = unlimited)
|
||||||
MaxPurchasePerUser int `json:"max_purchase_per_user" gorm:"type:int;default:0"`
|
MaxPurchasePerUser int `json:"max_purchase_per_user" gorm:"type:int;default:0"`
|
||||||
|
|
||||||
|
// Upgrade user group after purchase (empty = no change)
|
||||||
|
UpgradeGroup string `json:"upgrade_group" gorm:"type:varchar(64);default:''"`
|
||||||
|
|
||||||
// Total quota (amount in quota units, 0 = unlimited)
|
// Total quota (amount in quota units, 0 = unlimited)
|
||||||
TotalAmount int64 `json:"total_amount" gorm:"type:bigint;not null;default:0"`
|
TotalAmount int64 `json:"total_amount" gorm:"type:bigint;not null;default:0"`
|
||||||
|
|
||||||
@@ -244,6 +247,9 @@ type UserSubscription struct {
|
|||||||
LastResetTime int64 `json:"last_reset_time" gorm:"type:bigint;default:0"`
|
LastResetTime int64 `json:"last_reset_time" gorm:"type:bigint;default:0"`
|
||||||
NextResetTime int64 `json:"next_reset_time" gorm:"type:bigint;default:0;index"`
|
NextResetTime int64 `json:"next_reset_time" gorm:"type:bigint;default:0;index"`
|
||||||
|
|
||||||
|
UpgradeGroup string `json:"upgrade_group" gorm:"type:varchar(64);default:''"`
|
||||||
|
PrevUserGroup string `json:"prev_user_group" gorm:"type:varchar(64);default:''"`
|
||||||
|
|
||||||
CreatedAt int64 `json:"created_at" gorm:"bigint"`
|
CreatedAt int64 `json:"created_at" gorm:"bigint"`
|
||||||
UpdatedAt int64 `json:"updated_at" gorm:"bigint"`
|
UpdatedAt int64 `json:"updated_at" gorm:"bigint"`
|
||||||
}
|
}
|
||||||
@@ -379,6 +385,55 @@ func CountUserSubscriptionsByPlan(userId int, planId int) (int64, error) {
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getUserGroupByIdTx(tx *gorm.DB, userId int) (string, error) {
|
||||||
|
if userId <= 0 {
|
||||||
|
return "", errors.New("invalid userId")
|
||||||
|
}
|
||||||
|
if tx == nil {
|
||||||
|
tx = DB
|
||||||
|
}
|
||||||
|
var group string
|
||||||
|
if err := tx.Model(&User{}).Where("id = ?", userId).Select(commonGroupCol).Find(&group).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downgradeUserGroupForSubscriptionTx(tx *gorm.DB, sub *UserSubscription, now int64) (string, error) {
|
||||||
|
if tx == nil || sub == nil {
|
||||||
|
return "", errors.New("invalid downgrade args")
|
||||||
|
}
|
||||||
|
upgradeGroup := strings.TrimSpace(sub.UpgradeGroup)
|
||||||
|
if upgradeGroup == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
currentGroup, err := getUserGroupByIdTx(tx, sub.UserId)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if currentGroup != upgradeGroup {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
var activeSub UserSubscription
|
||||||
|
activeQuery := tx.Where("user_id = ? AND status = ? AND end_time > ? AND id <> ? AND upgrade_group <> ''",
|
||||||
|
sub.UserId, "active", now, sub.Id).
|
||||||
|
Order("end_time desc, id desc").
|
||||||
|
Limit(1).
|
||||||
|
Find(&activeSub)
|
||||||
|
if activeQuery.Error == nil && activeQuery.RowsAffected > 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
prevGroup := strings.TrimSpace(sub.PrevUserGroup)
|
||||||
|
if prevGroup == "" || prevGroup == currentGroup {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err := tx.Model(&User{}).Where("id = ?", sub.UserId).
|
||||||
|
Update("group", prevGroup).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return prevGroup, 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) (*UserSubscription, error) {
|
||||||
if tx == nil {
|
if tx == nil {
|
||||||
return nil, errors.New("tx is nil")
|
return nil, errors.New("tx is nil")
|
||||||
@@ -412,6 +467,21 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio
|
|||||||
if nextReset > 0 {
|
if nextReset > 0 {
|
||||||
lastReset = now.Unix()
|
lastReset = now.Unix()
|
||||||
}
|
}
|
||||||
|
upgradeGroup := strings.TrimSpace(plan.UpgradeGroup)
|
||||||
|
prevGroup := ""
|
||||||
|
if upgradeGroup != "" {
|
||||||
|
currentGroup, err := getUserGroupByIdTx(tx, userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if currentGroup != upgradeGroup {
|
||||||
|
prevGroup = currentGroup
|
||||||
|
if err := tx.Model(&User{}).Where("id = ?", userId).
|
||||||
|
Update("group", upgradeGroup).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
sub := &UserSubscription{
|
sub := &UserSubscription{
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
PlanId: plan.Id,
|
PlanId: plan.Id,
|
||||||
@@ -423,6 +493,8 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio
|
|||||||
Source: source,
|
Source: source,
|
||||||
LastResetTime: lastReset,
|
LastResetTime: lastReset,
|
||||||
NextResetTime: nextReset,
|
NextResetTime: nextReset,
|
||||||
|
UpgradeGroup: upgradeGroup,
|
||||||
|
PrevUserGroup: prevGroup,
|
||||||
CreatedAt: common.GetTimestamp(),
|
CreatedAt: common.GetTimestamp(),
|
||||||
UpdatedAt: common.GetTimestamp(),
|
UpdatedAt: common.GetTimestamp(),
|
||||||
}
|
}
|
||||||
@@ -445,6 +517,7 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error {
|
|||||||
var logPlanTitle string
|
var logPlanTitle string
|
||||||
var logMoney float64
|
var logMoney float64
|
||||||
var logPaymentMethod string
|
var logPaymentMethod string
|
||||||
|
var upgradeGroup string
|
||||||
err := DB.Transaction(func(tx *gorm.DB) error {
|
err := DB.Transaction(func(tx *gorm.DB) error {
|
||||||
var order SubscriptionOrder
|
var order SubscriptionOrder
|
||||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil {
|
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil {
|
||||||
@@ -463,6 +536,7 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error {
|
|||||||
if !plan.Enabled {
|
if !plan.Enabled {
|
||||||
// still allow completion for already purchased orders
|
// still allow completion for already purchased orders
|
||||||
}
|
}
|
||||||
|
upgradeGroup = strings.TrimSpace(plan.UpgradeGroup)
|
||||||
_, err = CreateUserSubscriptionFromPlanTx(tx, order.UserId, plan, "order")
|
_, err = CreateUserSubscriptionFromPlanTx(tx, order.UserId, plan, "order")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -487,6 +561,9 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if upgradeGroup != "" && logUserId > 0 {
|
||||||
|
_ = UpdateUserGroupCache(logUserId, upgradeGroup)
|
||||||
|
}
|
||||||
if logUserId > 0 {
|
if logUserId > 0 {
|
||||||
msg := fmt.Sprintf("订阅购买成功,套餐: %s,支付金额: %.2f,支付方式: %s", logPlanTitle, logMoney, logPaymentMethod)
|
msg := fmt.Sprintf("订阅购买成功,套餐: %s,支付金额: %.2f,支付方式: %s", logPlanTitle, logMoney, logPaymentMethod)
|
||||||
RecordLog(logUserId, LogTypeTopup, msg)
|
RecordLog(logUserId, LogTypeTopup, msg)
|
||||||
@@ -551,18 +628,26 @@ func ExpireSubscriptionOrder(tradeNo string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Admin bind (no payment). Creates a UserSubscription from a plan.
|
// Admin bind (no payment). Creates a UserSubscription from a plan.
|
||||||
func AdminBindSubscription(userId int, planId int, sourceNote string) error {
|
func AdminBindSubscription(userId int, planId int, sourceNote string) (string, error) {
|
||||||
if userId <= 0 || planId <= 0 {
|
if userId <= 0 || planId <= 0 {
|
||||||
return errors.New("invalid userId or planId")
|
return "", errors.New("invalid userId or planId")
|
||||||
}
|
}
|
||||||
plan, err := GetSubscriptionPlanById(planId)
|
plan, err := GetSubscriptionPlanById(planId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
return DB.Transaction(func(tx *gorm.DB) error {
|
err = DB.Transaction(func(tx *gorm.DB) error {
|
||||||
_, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, "admin")
|
_, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, "admin")
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(plan.UpgradeGroup) != "" {
|
||||||
|
_ = UpdateUserGroupCache(userId, plan.UpgradeGroup)
|
||||||
|
return fmt.Sprintf("用户分组将升级到 %s", plan.UpgradeGroup), nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllActiveUserSubscriptions returns all active subscriptions for a user.
|
// GetAllActiveUserSubscriptions returns all active subscriptions for a user.
|
||||||
@@ -611,26 +696,89 @@ func buildSubscriptionSummaries(subs []UserSubscription) []SubscriptionSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AdminInvalidateUserSubscription marks a user subscription as cancelled and ends it immediately.
|
// AdminInvalidateUserSubscription marks a user subscription as cancelled and ends it immediately.
|
||||||
func AdminInvalidateUserSubscription(userSubscriptionId int) error {
|
func AdminInvalidateUserSubscription(userSubscriptionId int) (string, error) {
|
||||||
if userSubscriptionId <= 0 {
|
if userSubscriptionId <= 0 {
|
||||||
return errors.New("invalid userSubscriptionId")
|
return "", errors.New("invalid userSubscriptionId")
|
||||||
}
|
}
|
||||||
now := common.GetTimestamp()
|
now := common.GetTimestamp()
|
||||||
return DB.Model(&UserSubscription{}).
|
cacheGroup := ""
|
||||||
Where("id = ?", userSubscriptionId).
|
downgradeGroup := ""
|
||||||
Updates(map[string]interface{}{
|
var userId int
|
||||||
|
err := DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var sub UserSubscription
|
||||||
|
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||||
|
Where("id = ?", userSubscriptionId).First(&sub).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userId = sub.UserId
|
||||||
|
if err := tx.Model(&sub).Updates(map[string]interface{}{
|
||||||
"status": "cancelled",
|
"status": "cancelled",
|
||||||
"end_time": now,
|
"end_time": now,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}).Error
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target, err := downgradeUserGroupForSubscriptionTx(tx, &sub, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if target != "" {
|
||||||
|
cacheGroup = target
|
||||||
|
downgradeGroup = target
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if cacheGroup != "" && userId > 0 {
|
||||||
|
_ = UpdateUserGroupCache(userId, cacheGroup)
|
||||||
|
}
|
||||||
|
if downgradeGroup != "" {
|
||||||
|
return fmt.Sprintf("用户分组将回退到 %s", downgradeGroup), nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminDeleteUserSubscription hard-deletes a user subscription.
|
// AdminDeleteUserSubscription hard-deletes a user subscription.
|
||||||
func AdminDeleteUserSubscription(userSubscriptionId int) error {
|
func AdminDeleteUserSubscription(userSubscriptionId int) (string, error) {
|
||||||
if userSubscriptionId <= 0 {
|
if userSubscriptionId <= 0 {
|
||||||
return errors.New("invalid userSubscriptionId")
|
return "", errors.New("invalid userSubscriptionId")
|
||||||
}
|
}
|
||||||
return DB.Where("id = ?", userSubscriptionId).Delete(&UserSubscription{}).Error
|
now := common.GetTimestamp()
|
||||||
|
cacheGroup := ""
|
||||||
|
downgradeGroup := ""
|
||||||
|
var userId int
|
||||||
|
err := DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var sub UserSubscription
|
||||||
|
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||||
|
Where("id = ?", userSubscriptionId).First(&sub).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userId = sub.UserId
|
||||||
|
target, err := downgradeUserGroupForSubscriptionTx(tx, &sub, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if target != "" {
|
||||||
|
cacheGroup = target
|
||||||
|
downgradeGroup = target
|
||||||
|
}
|
||||||
|
if err := tx.Where("id = ?", userSubscriptionId).Delete(&UserSubscription{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if cacheGroup != "" && userId > 0 {
|
||||||
|
_ = UpdateUserGroupCache(userId, cacheGroup)
|
||||||
|
}
|
||||||
|
if downgradeGroup != "" {
|
||||||
|
return fmt.Sprintf("用户分组将回退到 %s", downgradeGroup), nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubscriptionPreConsumeResult struct {
|
type SubscriptionPreConsumeResult struct {
|
||||||
@@ -641,6 +789,93 @@ type SubscriptionPreConsumeResult struct {
|
|||||||
AmountUsedAfter int64
|
AmountUsedAfter int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExpireDueSubscriptions marks expired subscriptions and handles group downgrade.
|
||||||
|
func ExpireDueSubscriptions(limit int) (int, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
now := GetDBTimestamp()
|
||||||
|
var subs []UserSubscription
|
||||||
|
if err := DB.Where("status = ? AND end_time > 0 AND end_time <= ?", "active", now).
|
||||||
|
Order("end_time asc, id asc").
|
||||||
|
Limit(limit).
|
||||||
|
Find(&subs).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(subs) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
expiredCount := 0
|
||||||
|
userIds := make(map[int]struct{}, len(subs))
|
||||||
|
for _, sub := range subs {
|
||||||
|
if sub.UserId > 0 {
|
||||||
|
userIds[sub.UserId] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for userId := range userIds {
|
||||||
|
cacheGroup := ""
|
||||||
|
err := DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
res := tx.Model(&UserSubscription{}).
|
||||||
|
Where("user_id = ? AND status = ? AND end_time > 0 AND end_time <= ?", userId, "active", now).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": "expired",
|
||||||
|
"updated_at": common.GetTimestamp(),
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
expiredCount += int(res.RowsAffected)
|
||||||
|
|
||||||
|
// If there's an active upgraded subscription, keep current group.
|
||||||
|
var activeSub UserSubscription
|
||||||
|
activeQuery := tx.Where("user_id = ? AND status = ? AND end_time > ? AND upgrade_group <> ''",
|
||||||
|
userId, "active", now).
|
||||||
|
Order("end_time desc, id desc").
|
||||||
|
Limit(1).
|
||||||
|
Find(&activeSub)
|
||||||
|
if activeQuery.Error == nil && activeQuery.RowsAffected > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// No active upgraded subscription, downgrade to previous group if needed.
|
||||||
|
var lastExpired UserSubscription
|
||||||
|
expiredQuery := tx.Where("user_id = ? AND status = ? AND upgrade_group <> ''",
|
||||||
|
userId, "expired").
|
||||||
|
Order("end_time desc, id desc").
|
||||||
|
Limit(1).
|
||||||
|
Find(&lastExpired)
|
||||||
|
if expiredQuery.Error != nil || expiredQuery.RowsAffected == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
upgradeGroup := strings.TrimSpace(lastExpired.UpgradeGroup)
|
||||||
|
prevGroup := strings.TrimSpace(lastExpired.PrevUserGroup)
|
||||||
|
if upgradeGroup == "" || prevGroup == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
currentGroup, err := getUserGroupByIdTx(tx, userId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if currentGroup != upgradeGroup || currentGroup == prevGroup {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := tx.Model(&User{}).Where("id = ?", userId).
|
||||||
|
Update("group", prevGroup).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cacheGroup = prevGroup
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return expiredCount, err
|
||||||
|
}
|
||||||
|
if cacheGroup != "" {
|
||||||
|
_ = UpdateUserGroupCache(userId, cacheGroup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expiredCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SubscriptionPreConsumeRecord stores idempotent pre-consume operations per request.
|
// SubscriptionPreConsumeRecord stores idempotent pre-consume operations per request.
|
||||||
type SubscriptionPreConsumeRecord struct {
|
type SubscriptionPreConsumeRecord struct {
|
||||||
Id int `json:"id"`
|
Id int `json:"id"`
|
||||||
|
|||||||
@@ -204,6 +204,10 @@ func updateUserGroupCache(userId int, group string) error {
|
|||||||
return common.RedisHSetField(getUserCacheKey(userId), "Group", group)
|
return common.RedisHSetField(getUserCacheKey(userId), "Group", group)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func UpdateUserGroupCache(userId int, group string) error {
|
||||||
|
return updateUserGroupCache(userId, group)
|
||||||
|
}
|
||||||
|
|
||||||
func updateUserNameCache(userId int, username string) error {
|
func updateUserNameCache(userId int, username string) error {
|
||||||
if !common.RedisEnabled {
|
if !common.RedisEnabled {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -52,6 +52,21 @@ func runSubscriptionQuotaResetOnce() {
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
totalReset := 0
|
totalReset := 0
|
||||||
|
totalExpired := 0
|
||||||
|
for {
|
||||||
|
n, err := model.ExpireDueSubscriptions(subscriptionResetBatchSize)
|
||||||
|
if err != nil {
|
||||||
|
logger.LogWarn(ctx, fmt.Sprintf("subscription expire task failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
totalExpired += n
|
||||||
|
if n < subscriptionResetBatchSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
for {
|
for {
|
||||||
n, err := model.ResetDueSubscriptions(subscriptionResetBatchSize)
|
n, err := model.ResetDueSubscriptions(subscriptionResetBatchSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -72,7 +87,7 @@ func runSubscriptionQuotaResetOnce() {
|
|||||||
subscriptionCleanupLast.Store(time.Now().Unix())
|
subscriptionCleanupLast.Store(time.Now().Unix())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if totalReset > 0 && common.DebugEnabled {
|
if common.DebugEnabled && (totalReset > 0 || totalExpired > 0) {
|
||||||
logger.LogDebug(ctx, "subscription quota reset: reset_count=%d", totalReset)
|
logger.LogDebug(ctx, "subscription maintenance: reset_count=%d, expired_count=%d", totalReset, totalExpired)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ const renderPlanTitle = (text, record, t) => {
|
|||||||
</Text>
|
</Text>
|
||||||
<Text type='tertiary'>{t('总额度')}</Text>
|
<Text type='tertiary'>{t('总额度')}</Text>
|
||||||
<Text>{plan?.total_amount > 0 ? plan.total_amount : t('不限')}</Text>
|
<Text>{plan?.total_amount > 0 ? plan.total_amount : t('不限')}</Text>
|
||||||
|
<Text type='tertiary'>{t('升级分组')}</Text>
|
||||||
|
<Text>{plan?.upgrade_group ? plan.upgrade_group : t('不升级')}</Text>
|
||||||
<Text type='tertiary'>{t('购买上限')}</Text>
|
<Text type='tertiary'>{t('购买上限')}</Text>
|
||||||
<Text>
|
<Text>
|
||||||
{plan?.max_purchase_per_user > 0
|
{plan?.max_purchase_per_user > 0
|
||||||
@@ -168,6 +170,15 @@ const renderTotalAmount = (text, record, t) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderUpgradeGroup = (text, record, t) => {
|
||||||
|
const group = record?.plan?.upgrade_group || '';
|
||||||
|
return (
|
||||||
|
<Text type={group ? 'secondary' : 'tertiary'}>
|
||||||
|
{group ? group : t('不升级')}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderResetPeriod = (text, record, t) => {
|
const renderResetPeriod = (text, record, t) => {
|
||||||
const period = record?.plan?.quota_reset_period || 'never';
|
const period = record?.plan?.quota_reset_period || 'never';
|
||||||
const isNever = period === 'never';
|
const isNever = period === 'never';
|
||||||
@@ -291,7 +302,7 @@ export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('有效期'),
|
title: t('有效期'),
|
||||||
width: 80,
|
width: 100,
|
||||||
render: (text, record) => renderDuration(text, record, t),
|
render: (text, record) => renderDuration(text, record, t),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -315,6 +326,11 @@ export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => {
|
|||||||
width: 100,
|
width: 100,
|
||||||
render: (text, record) => renderTotalAmount(text, record, t),
|
render: (text, record) => renderTotalAmount(text, record, t),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('升级分组'),
|
||||||
|
width: 100,
|
||||||
|
render: (text, record) => renderUpgradeGroup(text, record, t),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t('操作'),
|
title: t('操作'),
|
||||||
dataIndex: 'operate',
|
dataIndex: 'operate',
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Button,
|
Button,
|
||||||
@@ -74,6 +74,8 @@ const AddEditSubscriptionModal = ({
|
|||||||
t,
|
t,
|
||||||
}) => {
|
}) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [groupOptions, setGroupOptions] = useState([]);
|
||||||
|
const [groupLoading, setGroupLoading] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const formApiRef = useRef(null);
|
const formApiRef = useRef(null);
|
||||||
const isEdit = editingPlan?.plan?.id !== undefined;
|
const isEdit = editingPlan?.plan?.id !== undefined;
|
||||||
@@ -93,6 +95,7 @@ const AddEditSubscriptionModal = ({
|
|||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
max_purchase_per_user: 0,
|
max_purchase_per_user: 0,
|
||||||
total_amount: 0,
|
total_amount: 0,
|
||||||
|
upgrade_group: '',
|
||||||
stripe_price_id: '',
|
stripe_price_id: '',
|
||||||
creem_product_id: '',
|
creem_product_id: '',
|
||||||
});
|
});
|
||||||
@@ -116,11 +119,27 @@ const AddEditSubscriptionModal = ({
|
|||||||
sort_order: Number(p.sort_order || 0),
|
sort_order: Number(p.sort_order || 0),
|
||||||
max_purchase_per_user: Number(p.max_purchase_per_user || 0),
|
max_purchase_per_user: Number(p.max_purchase_per_user || 0),
|
||||||
total_amount: Number(p.total_amount || 0),
|
total_amount: Number(p.total_amount || 0),
|
||||||
|
upgrade_group: p.upgrade_group || '',
|
||||||
stripe_price_id: p.stripe_price_id || '',
|
stripe_price_id: p.stripe_price_id || '',
|
||||||
creem_product_id: p.creem_product_id || '',
|
creem_product_id: p.creem_product_id || '',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
setGroupLoading(true);
|
||||||
|
API.get('/api/group')
|
||||||
|
.then((res) => {
|
||||||
|
if (res.data?.success) {
|
||||||
|
setGroupOptions(res.data?.data || []);
|
||||||
|
} else {
|
||||||
|
setGroupOptions([]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setGroupOptions([]))
|
||||||
|
.finally(() => setGroupLoading(false));
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
const submit = async (values) => {
|
const submit = async (values) => {
|
||||||
if (!values.title || values.title.trim() === '') {
|
if (!values.title || values.title.trim() === '') {
|
||||||
showError(t('套餐标题不能为空'));
|
showError(t('套餐标题不能为空'));
|
||||||
@@ -143,6 +162,7 @@ const AddEditSubscriptionModal = ({
|
|||||||
sort_order: Number(values.sort_order || 0),
|
sort_order: Number(values.sort_order || 0),
|
||||||
max_purchase_per_user: Number(values.max_purchase_per_user || 0),
|
max_purchase_per_user: Number(values.max_purchase_per_user || 0),
|
||||||
total_amount: Number(values.total_amount || 0),
|
total_amount: Number(values.total_amount || 0),
|
||||||
|
upgrade_group: values.upgrade_group || '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (editingPlan?.plan?.id) {
|
if (editingPlan?.plan?.id) {
|
||||||
@@ -257,6 +277,7 @@ const AddEditSubscriptionModal = ({
|
|||||||
field='title'
|
field='title'
|
||||||
label={t('套餐标题')}
|
label={t('套餐标题')}
|
||||||
placeholder={t('例如:基础套餐')}
|
placeholder={t('例如:基础套餐')}
|
||||||
|
required
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: t('请输入套餐标题') },
|
{ required: true, message: t('请输入套餐标题') },
|
||||||
]}
|
]}
|
||||||
@@ -277,6 +298,7 @@ const AddEditSubscriptionModal = ({
|
|||||||
<Form.InputNumber
|
<Form.InputNumber
|
||||||
field='price_amount'
|
field='price_amount'
|
||||||
label={t('实付金额')}
|
label={t('实付金额')}
|
||||||
|
required
|
||||||
min={0}
|
min={0}
|
||||||
precision={2}
|
precision={2}
|
||||||
rules={[{ required: true, message: t('请输入金额') }]}
|
rules={[{ required: true, message: t('请输入金额') }]}
|
||||||
@@ -288,6 +310,7 @@ const AddEditSubscriptionModal = ({
|
|||||||
<Form.AutoComplete
|
<Form.AutoComplete
|
||||||
field='total_amount'
|
field='total_amount'
|
||||||
label={t('总额度')}
|
label={t('总额度')}
|
||||||
|
required
|
||||||
type='number'
|
type='number'
|
||||||
rules={[{ required: true, message: t('请输入总额度') }]}
|
rules={[{ required: true, message: t('请输入总额度') }]}
|
||||||
extraText={`${t('0 表示不限')} · ${renderQuotaWithPrompt(
|
extraText={`${t('0 表示不限')} · ${renderQuotaWithPrompt(
|
||||||
@@ -305,6 +328,23 @@ const AddEditSubscriptionModal = ({
|
|||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Select
|
||||||
|
field='upgrade_group'
|
||||||
|
label={t('升级分组')}
|
||||||
|
showClear
|
||||||
|
loading={groupLoading}
|
||||||
|
placeholder={t('不升级')}
|
||||||
|
>
|
||||||
|
<Select.Option value=''>{t('不升级')}</Select.Option>
|
||||||
|
{(groupOptions || []).map((g) => (
|
||||||
|
<Select.Option key={g} value={g}>
|
||||||
|
{g}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Form.Select>
|
||||||
|
</Col>
|
||||||
|
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
field='currency'
|
field='currency'
|
||||||
@@ -369,6 +409,7 @@ const AddEditSubscriptionModal = ({
|
|||||||
<Form.Select
|
<Form.Select
|
||||||
field='duration_unit'
|
field='duration_unit'
|
||||||
label={t('有效期单位')}
|
label={t('有效期单位')}
|
||||||
|
required
|
||||||
rules={[{ required: true }]}
|
rules={[{ required: true }]}
|
||||||
>
|
>
|
||||||
{durationUnitOptions.map((o) => (
|
{durationUnitOptions.map((o) => (
|
||||||
@@ -384,6 +425,7 @@ const AddEditSubscriptionModal = ({
|
|||||||
<Form.InputNumber
|
<Form.InputNumber
|
||||||
field='custom_seconds'
|
field='custom_seconds'
|
||||||
label={t('自定义秒数')}
|
label={t('自定义秒数')}
|
||||||
|
required
|
||||||
min={0}
|
min={0}
|
||||||
precision={0}
|
precision={0}
|
||||||
rules={[{ required: true, message: t('请输入秒数') }]}
|
rules={[{ required: true, message: t('请输入秒数') }]}
|
||||||
@@ -393,6 +435,7 @@ const AddEditSubscriptionModal = ({
|
|||||||
<Form.InputNumber
|
<Form.InputNumber
|
||||||
field='duration_value'
|
field='duration_value'
|
||||||
label={t('有效期数值')}
|
label={t('有效期数值')}
|
||||||
|
required
|
||||||
min={1}
|
min={1}
|
||||||
precision={0}
|
precision={0}
|
||||||
rules={[{ required: true, message: t('请输入数值') }]}
|
rules={[{ required: true, message: t('请输入数值') }]}
|
||||||
@@ -441,6 +484,7 @@ const AddEditSubscriptionModal = ({
|
|||||||
<Form.InputNumber
|
<Form.InputNumber
|
||||||
field='quota_reset_custom_seconds'
|
field='quota_reset_custom_seconds'
|
||||||
label={t('自定义秒数')}
|
label={t('自定义秒数')}
|
||||||
|
required
|
||||||
min={60}
|
min={60}
|
||||||
precision={0}
|
precision={0}
|
||||||
rules={[{ required: true, message: t('请输入秒数') }]}
|
rules={[{ required: true, message: t('请输入秒数') }]}
|
||||||
|
|||||||
@@ -179,7 +179,8 @@ const UserSubscriptionsModal = ({ visible, onCancel, user, t, onSuccess }) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (res.data?.success) {
|
if (res.data?.success) {
|
||||||
showSuccess(t('新增成功'));
|
const msg = res.data?.data?.message;
|
||||||
|
showSuccess(msg ? msg : t('新增成功'));
|
||||||
setSelectedPlanId(null);
|
setSelectedPlanId(null);
|
||||||
await loadUserSubscriptions();
|
await loadUserSubscriptions();
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
@@ -204,7 +205,8 @@ const UserSubscriptionsModal = ({ visible, onCancel, user, t, onSuccess }) => {
|
|||||||
`/api/subscription/admin/user_subscriptions/${subId}/invalidate`,
|
`/api/subscription/admin/user_subscriptions/${subId}/invalidate`,
|
||||||
);
|
);
|
||||||
if (res.data?.success) {
|
if (res.data?.success) {
|
||||||
showSuccess(t('已作废'));
|
const msg = res.data?.data?.message;
|
||||||
|
showSuccess(msg ? msg : t('已作废'));
|
||||||
await loadUserSubscriptions();
|
await loadUserSubscriptions();
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} else {
|
} else {
|
||||||
@@ -229,7 +231,8 @@ const UserSubscriptionsModal = ({ visible, onCancel, user, t, onSuccess }) => {
|
|||||||
`/api/subscription/admin/user_subscriptions/${subId}`,
|
`/api/subscription/admin/user_subscriptions/${subId}`,
|
||||||
);
|
);
|
||||||
if (res.data?.success) {
|
if (res.data?.success) {
|
||||||
showSuccess(t('已删除'));
|
const msg = res.data?.data?.message;
|
||||||
|
showSuccess(msg ? msg : t('已删除'));
|
||||||
await loadUserSubscriptions();
|
await loadUserSubscriptions();
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
import { API, showError, showSuccess } from '../../helpers';
|
import { API, showError, showSuccess, renderQuota } from '../../helpers';
|
||||||
import { getCurrencyConfig, stringToColor } from '../../helpers/render';
|
import { getCurrencyConfig } from '../../helpers/render';
|
||||||
import { Crown, RefreshCw, Sparkles } from 'lucide-react';
|
import { Crown, RefreshCw, Sparkles } from 'lucide-react';
|
||||||
import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal';
|
import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal';
|
||||||
|
|
||||||
@@ -232,6 +232,16 @@ const SubscriptionPlansCard = ({
|
|||||||
return map;
|
return map;
|
||||||
}, [allSubscriptions]);
|
}, [allSubscriptions]);
|
||||||
|
|
||||||
|
const planTitleMap = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
(plans || []).forEach((p) => {
|
||||||
|
const plan = p?.plan;
|
||||||
|
if (!plan?.id) return;
|
||||||
|
map.set(plan.id, plan.title || '');
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [plans]);
|
||||||
|
|
||||||
const getPlanPurchaseCount = (planId) =>
|
const getPlanPurchaseCount = (planId) =>
|
||||||
planPurchaseCountMap.get(planId) || 0;
|
planPurchaseCountMap.get(planId) || 0;
|
||||||
|
|
||||||
@@ -374,6 +384,8 @@ const SubscriptionPlansCard = ({
|
|||||||
totalAmount > 0
|
totalAmount > 0
|
||||||
? Math.max(0, totalAmount - usedAmount)
|
? Math.max(0, totalAmount - usedAmount)
|
||||||
: 0;
|
: 0;
|
||||||
|
const planTitle =
|
||||||
|
planTitleMap.get(subscription?.plan_id) || '';
|
||||||
const remainDays = getRemainingDays(sub);
|
const remainDays = getRemainingDays(sub);
|
||||||
const usagePercent = getUsagePercent(sub);
|
const usagePercent = getUsagePercent(sub);
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
@@ -387,7 +399,9 @@ const SubscriptionPlansCard = ({
|
|||||||
<div className='flex items-center justify-between text-xs mb-2'>
|
<div className='flex items-center justify-between text-xs mb-2'>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<span className='font-medium'>
|
<span className='font-medium'>
|
||||||
{t('订阅')} #{subscription?.id}
|
{planTitle
|
||||||
|
? `${planTitle} · ${t('订阅')} #${subscription?.id}`
|
||||||
|
: `${t('订阅')} #${subscription?.id}`}
|
||||||
</span>
|
</span>
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<Tag
|
<Tag
|
||||||
@@ -418,9 +432,19 @@ const SubscriptionPlansCard = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className='text-xs text-gray-500 mb-2'>
|
<div className='text-xs text-gray-500 mb-2'>
|
||||||
{t('总额度')}:{' '}
|
{t('总额度')}:{' '}
|
||||||
{totalAmount > 0
|
{totalAmount > 0 ? (
|
||||||
? `${usedAmount}/${totalAmount} · ${t('剩余')} ${remainAmount}`
|
<Tooltip
|
||||||
: t('不限')}
|
content={`${t('原生额度')}:${usedAmount}/${totalAmount} · ${t('剩余')} ${remainAmount}`}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{renderQuota(usedAmount)}/
|
||||||
|
{renderQuota(totalAmount)} · {t('剩余')}{' '}
|
||||||
|
{renderQuota(remainAmount)}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
t('不限')
|
||||||
|
)}
|
||||||
{totalAmount > 0 && (
|
{totalAmount > 0 && (
|
||||||
<span className='ml-2'>
|
<span className='ml-2'>
|
||||||
{t('已用')} {usagePercent}%
|
{t('已用')} {usagePercent}%
|
||||||
@@ -453,18 +477,30 @@ const SubscriptionPlansCard = ({
|
|||||||
);
|
);
|
||||||
const isPopular = index === 0 && plans.length > 1;
|
const isPopular = index === 0 && plans.length > 1;
|
||||||
const limit = Number(plan?.max_purchase_per_user || 0);
|
const limit = Number(plan?.max_purchase_per_user || 0);
|
||||||
const limitLabel =
|
const limitLabel = limit > 0 ? `${t('限购')} ${limit}` : null;
|
||||||
limit > 0 ? `${t('限购')} ${limit}` : t('不限购');
|
|
||||||
const totalLabel =
|
const totalLabel =
|
||||||
totalAmount > 0
|
totalAmount > 0
|
||||||
? `${t('总额度')}: ${totalAmount}`
|
? `${t('总额度')}: ${renderQuota(totalAmount)}`
|
||||||
: `${t('总额度')}: ${t('不限')}`;
|
: `${t('总额度')}: ${t('不限')}`;
|
||||||
const planTags = [
|
const upgradeLabel = plan?.upgrade_group
|
||||||
`${t('有效期')}: ${formatDuration(plan, t)}`,
|
? `${t('升级分组')}: ${plan.upgrade_group}`
|
||||||
`${t('重置')}: ${formatResetPeriod(plan, t)}`,
|
: null;
|
||||||
totalLabel,
|
const resetLabel =
|
||||||
limitLabel,
|
formatResetPeriod(plan, t) === t('不重置')
|
||||||
];
|
? null
|
||||||
|
: `${t('额度重置')}: ${formatResetPeriod(plan, t)}`;
|
||||||
|
const planBenefits = [
|
||||||
|
{ label: `${t('有效期')}: ${formatDuration(plan, t)}` },
|
||||||
|
resetLabel ? { label: resetLabel } : null,
|
||||||
|
totalAmount > 0
|
||||||
|
? {
|
||||||
|
label: totalLabel,
|
||||||
|
tooltip: `${t('原生额度')}:${totalAmount}`,
|
||||||
|
}
|
||||||
|
: { label: totalLabel },
|
||||||
|
limitLabel ? { label: limitLabel } : null,
|
||||||
|
upgradeLabel ? { label: upgradeLabel } : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -517,18 +553,33 @@ const SubscriptionPlansCard = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 属性标签 */}
|
{/* 套餐权益描述 */}
|
||||||
<div className='flex flex-wrap justify-center gap-2 pb-2'>
|
<div className='flex flex-col items-center gap-1 pb-2'>
|
||||||
{planTags.map((tag) => (
|
{planBenefits.map((item) => {
|
||||||
<Tag
|
const content = (
|
||||||
key={tag}
|
<div className='flex items-center gap-2 text-xs text-gray-500'>
|
||||||
size='small'
|
<Badge dot type='tertiary' />
|
||||||
shape='circle'
|
<span>{item.label}</span>
|
||||||
color='white'
|
</div>
|
||||||
>
|
);
|
||||||
{tag}
|
if (!item.tooltip) {
|
||||||
</Tag>
|
return (
|
||||||
))}
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className='w-full flex justify-center'
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tooltip key={item.label} content={item.tooltip}>
|
||||||
|
<div className='w-full flex justify-center'>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider margin={12} />
|
<Divider margin={12} />
|
||||||
|
|||||||
Reference in New Issue
Block a user