mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-18 04:07:26 +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/model"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -135,6 +136,13 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
|
||||
common.ApiErrorMsg(c, "总额度不能为负数")
|
||||
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)
|
||||
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
|
||||
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
|
||||
@@ -183,6 +191,13 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
common.ApiErrorMsg(c, "总额度不能为负数")
|
||||
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)
|
||||
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
|
||||
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
|
||||
@@ -205,6 +220,7 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
"creem_product_id": req.Plan.CreemProductId,
|
||||
"max_purchase_per_user": req.Plan.MaxPurchasePerUser,
|
||||
"total_amount": req.Plan.TotalAmount,
|
||||
"upgrade_group": req.Plan.UpgradeGroup,
|
||||
"updated_at": common.GetTimestamp(),
|
||||
}
|
||||
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, "参数错误")
|
||||
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)
|
||||
return
|
||||
}
|
||||
if msg != "" {
|
||||
common.ApiSuccess(c, gin.H{"message": msg})
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
@@ -293,10 +314,15 @@ func AdminCreateUserSubscription(c *gin.Context) {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
if err := model.AdminBindSubscription(userId, req.PlanId, ""); err != nil {
|
||||
msg, err := model.AdminBindSubscription(userId, req.PlanId, "")
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if msg != "" {
|
||||
common.ApiSuccess(c, gin.H{"message": msg})
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
@@ -307,10 +333,15 @@ func AdminInvalidateUserSubscription(c *gin.Context) {
|
||||
common.ApiErrorMsg(c, "无效的订阅ID")
|
||||
return
|
||||
}
|
||||
if err := model.AdminInvalidateUserSubscription(subId); err != nil {
|
||||
msg, err := model.AdminInvalidateUserSubscription(subId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if msg != "" {
|
||||
common.ApiSuccess(c, gin.H{"message": msg})
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
@@ -321,9 +352,14 @@ func AdminDeleteUserSubscription(c *gin.Context) {
|
||||
common.ApiErrorMsg(c, "无效的订阅ID")
|
||||
return
|
||||
}
|
||||
if err := model.AdminDeleteUserSubscription(subId); err != nil {
|
||||
msg, err := model.AdminDeleteUserSubscription(subId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if msg != "" {
|
||||
common.ApiSuccess(c, gin.H{"message": msg})
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
@@ -165,6 +165,9 @@ type SubscriptionPlan struct {
|
||||
// Max purchases per user (0 = unlimited)
|
||||
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)
|
||||
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"`
|
||||
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"`
|
||||
UpdatedAt int64 `json:"updated_at" gorm:"bigint"`
|
||||
}
|
||||
@@ -379,6 +385,55 @@ func CountUserSubscriptionsByPlan(userId int, planId int) (int64, error) {
|
||||
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) {
|
||||
if tx == nil {
|
||||
return nil, errors.New("tx is nil")
|
||||
@@ -412,6 +467,21 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio
|
||||
if nextReset > 0 {
|
||||
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{
|
||||
UserId: userId,
|
||||
PlanId: plan.Id,
|
||||
@@ -423,6 +493,8 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio
|
||||
Source: source,
|
||||
LastResetTime: lastReset,
|
||||
NextResetTime: nextReset,
|
||||
UpgradeGroup: upgradeGroup,
|
||||
PrevUserGroup: prevGroup,
|
||||
CreatedAt: common.GetTimestamp(),
|
||||
UpdatedAt: common.GetTimestamp(),
|
||||
}
|
||||
@@ -445,6 +517,7 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error {
|
||||
var logPlanTitle string
|
||||
var logMoney float64
|
||||
var logPaymentMethod string
|
||||
var upgradeGroup string
|
||||
err := DB.Transaction(func(tx *gorm.DB) error {
|
||||
var order SubscriptionOrder
|
||||
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 {
|
||||
// still allow completion for already purchased orders
|
||||
}
|
||||
upgradeGroup = strings.TrimSpace(plan.UpgradeGroup)
|
||||
_, err = CreateUserSubscriptionFromPlanTx(tx, order.UserId, plan, "order")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -487,6 +561,9 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if upgradeGroup != "" && logUserId > 0 {
|
||||
_ = UpdateUserGroupCache(logUserId, upgradeGroup)
|
||||
}
|
||||
if logUserId > 0 {
|
||||
msg := fmt.Sprintf("订阅购买成功,套餐: %s,支付金额: %.2f,支付方式: %s", logPlanTitle, logMoney, logPaymentMethod)
|
||||
RecordLog(logUserId, LogTypeTopup, msg)
|
||||
@@ -551,18 +628,26 @@ func ExpireSubscriptionOrder(tradeNo string) error {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return errors.New("invalid userId or planId")
|
||||
return "", errors.New("invalid userId or planId")
|
||||
}
|
||||
plan, err := GetSubscriptionPlanById(planId)
|
||||
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")
|
||||
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.
|
||||
@@ -611,26 +696,89 @@ func buildSubscriptionSummaries(subs []UserSubscription) []SubscriptionSummary {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return errors.New("invalid userSubscriptionId")
|
||||
return "", errors.New("invalid userSubscriptionId")
|
||||
}
|
||||
now := common.GetTimestamp()
|
||||
return DB.Model(&UserSubscription{}).
|
||||
Where("id = ?", userSubscriptionId).
|
||||
Updates(map[string]interface{}{
|
||||
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
|
||||
if err := tx.Model(&sub).Updates(map[string]interface{}{
|
||||
"status": "cancelled",
|
||||
"end_time": 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.
|
||||
func AdminDeleteUserSubscription(userSubscriptionId int) error {
|
||||
func AdminDeleteUserSubscription(userSubscriptionId int) (string, error) {
|
||||
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 {
|
||||
@@ -641,6 +789,93 @@ type SubscriptionPreConsumeResult struct {
|
||||
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.
|
||||
type SubscriptionPreConsumeRecord struct {
|
||||
Id int `json:"id"`
|
||||
|
||||
@@ -204,6 +204,10 @@ func updateUserGroupCache(userId int, group string) error {
|
||||
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 {
|
||||
if !common.RedisEnabled {
|
||||
return nil
|
||||
|
||||
@@ -52,6 +52,21 @@ func runSubscriptionQuotaResetOnce() {
|
||||
|
||||
ctx := context.Background()
|
||||
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 {
|
||||
n, err := model.ResetDueSubscriptions(subscriptionResetBatchSize)
|
||||
if err != nil {
|
||||
@@ -72,7 +87,7 @@ func runSubscriptionQuotaResetOnce() {
|
||||
subscriptionCleanupLast.Store(time.Now().Unix())
|
||||
}
|
||||
}
|
||||
if totalReset > 0 && common.DebugEnabled {
|
||||
logger.LogDebug(ctx, "subscription quota reset: reset_count=%d", totalReset)
|
||||
if common.DebugEnabled && (totalReset > 0 || totalExpired > 0) {
|
||||
logger.LogDebug(ctx, "subscription maintenance: reset_count=%d, expired_count=%d", totalReset, totalExpired)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,8 @@ const renderPlanTitle = (text, record, t) => {
|
||||
</Text>
|
||||
<Text type='tertiary'>{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>
|
||||
{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 period = record?.plan?.quota_reset_period || 'never';
|
||||
const isNever = period === 'never';
|
||||
@@ -291,7 +302,7 @@ export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => {
|
||||
},
|
||||
{
|
||||
title: t('有效期'),
|
||||
width: 80,
|
||||
width: 100,
|
||||
render: (text, record) => renderDuration(text, record, t),
|
||||
},
|
||||
{
|
||||
@@ -315,6 +326,11 @@ export const getSubscriptionsColumns = ({ t, openEdit, setPlanEnabled }) => {
|
||||
width: 100,
|
||||
render: (text, record) => renderTotalAmount(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('升级分组'),
|
||||
width: 100,
|
||||
render: (text, record) => renderUpgradeGroup(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
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
|
||||
*/
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
@@ -74,6 +74,8 @@ const AddEditSubscriptionModal = ({
|
||||
t,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [groupLoading, setGroupLoading] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const formApiRef = useRef(null);
|
||||
const isEdit = editingPlan?.plan?.id !== undefined;
|
||||
@@ -93,6 +95,7 @@ const AddEditSubscriptionModal = ({
|
||||
sort_order: 0,
|
||||
max_purchase_per_user: 0,
|
||||
total_amount: 0,
|
||||
upgrade_group: '',
|
||||
stripe_price_id: '',
|
||||
creem_product_id: '',
|
||||
});
|
||||
@@ -116,11 +119,27 @@ const AddEditSubscriptionModal = ({
|
||||
sort_order: Number(p.sort_order || 0),
|
||||
max_purchase_per_user: Number(p.max_purchase_per_user || 0),
|
||||
total_amount: Number(p.total_amount || 0),
|
||||
upgrade_group: p.upgrade_group || '',
|
||||
stripe_price_id: p.stripe_price_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) => {
|
||||
if (!values.title || values.title.trim() === '') {
|
||||
showError(t('套餐标题不能为空'));
|
||||
@@ -143,6 +162,7 @@ const AddEditSubscriptionModal = ({
|
||||
sort_order: Number(values.sort_order || 0),
|
||||
max_purchase_per_user: Number(values.max_purchase_per_user || 0),
|
||||
total_amount: Number(values.total_amount || 0),
|
||||
upgrade_group: values.upgrade_group || '',
|
||||
},
|
||||
};
|
||||
if (editingPlan?.plan?.id) {
|
||||
@@ -257,6 +277,7 @@ const AddEditSubscriptionModal = ({
|
||||
field='title'
|
||||
label={t('套餐标题')}
|
||||
placeholder={t('例如:基础套餐')}
|
||||
required
|
||||
rules={[
|
||||
{ required: true, message: t('请输入套餐标题') },
|
||||
]}
|
||||
@@ -277,6 +298,7 @@ const AddEditSubscriptionModal = ({
|
||||
<Form.InputNumber
|
||||
field='price_amount'
|
||||
label={t('实付金额')}
|
||||
required
|
||||
min={0}
|
||||
precision={2}
|
||||
rules={[{ required: true, message: t('请输入金额') }]}
|
||||
@@ -288,6 +310,7 @@ const AddEditSubscriptionModal = ({
|
||||
<Form.AutoComplete
|
||||
field='total_amount'
|
||||
label={t('总额度')}
|
||||
required
|
||||
type='number'
|
||||
rules={[{ required: true, message: t('请输入总额度') }]}
|
||||
extraText={`${t('0 表示不限')} · ${renderQuotaWithPrompt(
|
||||
@@ -305,6 +328,23 @@ const AddEditSubscriptionModal = ({
|
||||
/>
|
||||
</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}>
|
||||
<Form.Input
|
||||
field='currency'
|
||||
@@ -369,6 +409,7 @@ const AddEditSubscriptionModal = ({
|
||||
<Form.Select
|
||||
field='duration_unit'
|
||||
label={t('有效期单位')}
|
||||
required
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
{durationUnitOptions.map((o) => (
|
||||
@@ -384,6 +425,7 @@ const AddEditSubscriptionModal = ({
|
||||
<Form.InputNumber
|
||||
field='custom_seconds'
|
||||
label={t('自定义秒数')}
|
||||
required
|
||||
min={0}
|
||||
precision={0}
|
||||
rules={[{ required: true, message: t('请输入秒数') }]}
|
||||
@@ -393,6 +435,7 @@ const AddEditSubscriptionModal = ({
|
||||
<Form.InputNumber
|
||||
field='duration_value'
|
||||
label={t('有效期数值')}
|
||||
required
|
||||
min={1}
|
||||
precision={0}
|
||||
rules={[{ required: true, message: t('请输入数值') }]}
|
||||
@@ -441,6 +484,7 @@ const AddEditSubscriptionModal = ({
|
||||
<Form.InputNumber
|
||||
field='quota_reset_custom_seconds'
|
||||
label={t('自定义秒数')}
|
||||
required
|
||||
min={60}
|
||||
precision={0}
|
||||
rules={[{ required: true, message: t('请输入秒数') }]}
|
||||
|
||||
@@ -179,7 +179,8 @@ const UserSubscriptionsModal = ({ visible, onCancel, user, t, onSuccess }) => {
|
||||
},
|
||||
);
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('新增成功'));
|
||||
const msg = res.data?.data?.message;
|
||||
showSuccess(msg ? msg : t('新增成功'));
|
||||
setSelectedPlanId(null);
|
||||
await loadUserSubscriptions();
|
||||
onSuccess?.();
|
||||
@@ -204,7 +205,8 @@ const UserSubscriptionsModal = ({ visible, onCancel, user, t, onSuccess }) => {
|
||||
`/api/subscription/admin/user_subscriptions/${subId}/invalidate`,
|
||||
);
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('已作废'));
|
||||
const msg = res.data?.data?.message;
|
||||
showSuccess(msg ? msg : t('已作废'));
|
||||
await loadUserSubscriptions();
|
||||
onSuccess?.();
|
||||
} else {
|
||||
@@ -229,7 +231,8 @@ const UserSubscriptionsModal = ({ visible, onCancel, user, t, onSuccess }) => {
|
||||
`/api/subscription/admin/user_subscriptions/${subId}`,
|
||||
);
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('已删除'));
|
||||
const msg = res.data?.data?.message;
|
||||
showSuccess(msg ? msg : t('已删除'));
|
||||
await loadUserSubscriptions();
|
||||
onSuccess?.();
|
||||
} else {
|
||||
|
||||
@@ -31,8 +31,8 @@ import {
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import { getCurrencyConfig, stringToColor } from '../../helpers/render';
|
||||
import { API, showError, showSuccess, renderQuota } from '../../helpers';
|
||||
import { getCurrencyConfig } from '../../helpers/render';
|
||||
import { Crown, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal';
|
||||
|
||||
@@ -232,6 +232,16 @@ const SubscriptionPlansCard = ({
|
||||
return map;
|
||||
}, [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) =>
|
||||
planPurchaseCountMap.get(planId) || 0;
|
||||
|
||||
@@ -374,6 +384,8 @@ const SubscriptionPlansCard = ({
|
||||
totalAmount > 0
|
||||
? Math.max(0, totalAmount - usedAmount)
|
||||
: 0;
|
||||
const planTitle =
|
||||
planTitleMap.get(subscription?.plan_id) || '';
|
||||
const remainDays = getRemainingDays(sub);
|
||||
const usagePercent = getUsagePercent(sub);
|
||||
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 gap-2'>
|
||||
<span className='font-medium'>
|
||||
{t('订阅')} #{subscription?.id}
|
||||
{planTitle
|
||||
? `${planTitle} · ${t('订阅')} #${subscription?.id}`
|
||||
: `${t('订阅')} #${subscription?.id}`}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Tag
|
||||
@@ -418,9 +432,19 @@ const SubscriptionPlansCard = ({
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 mb-2'>
|
||||
{t('总额度')}:{' '}
|
||||
{totalAmount > 0
|
||||
? `${usedAmount}/${totalAmount} · ${t('剩余')} ${remainAmount}`
|
||||
: t('不限')}
|
||||
{totalAmount > 0 ? (
|
||||
<Tooltip
|
||||
content={`${t('原生额度')}:${usedAmount}/${totalAmount} · ${t('剩余')} ${remainAmount}`}
|
||||
>
|
||||
<span>
|
||||
{renderQuota(usedAmount)}/
|
||||
{renderQuota(totalAmount)} · {t('剩余')}{' '}
|
||||
{renderQuota(remainAmount)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
t('不限')
|
||||
)}
|
||||
{totalAmount > 0 && (
|
||||
<span className='ml-2'>
|
||||
{t('已用')} {usagePercent}%
|
||||
@@ -453,18 +477,30 @@ const SubscriptionPlansCard = ({
|
||||
);
|
||||
const isPopular = index === 0 && plans.length > 1;
|
||||
const limit = Number(plan?.max_purchase_per_user || 0);
|
||||
const limitLabel =
|
||||
limit > 0 ? `${t('限购')} ${limit}` : t('不限购');
|
||||
const limitLabel = limit > 0 ? `${t('限购')} ${limit}` : null;
|
||||
const totalLabel =
|
||||
totalAmount > 0
|
||||
? `${t('总额度')}: ${totalAmount}`
|
||||
? `${t('总额度')}: ${renderQuota(totalAmount)}`
|
||||
: `${t('总额度')}: ${t('不限')}`;
|
||||
const planTags = [
|
||||
`${t('有效期')}: ${formatDuration(plan, t)}`,
|
||||
`${t('重置')}: ${formatResetPeriod(plan, t)}`,
|
||||
totalLabel,
|
||||
limitLabel,
|
||||
];
|
||||
const upgradeLabel = plan?.upgrade_group
|
||||
? `${t('升级分组')}: ${plan.upgrade_group}`
|
||||
: null;
|
||||
const resetLabel =
|
||||
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 (
|
||||
<Card
|
||||
@@ -517,18 +553,33 @@ const SubscriptionPlansCard = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 属性标签 */}
|
||||
<div className='flex flex-wrap justify-center gap-2 pb-2'>
|
||||
{planTags.map((tag) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
size='small'
|
||||
shape='circle'
|
||||
color='white'
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
{/* 套餐权益描述 */}
|
||||
<div className='flex flex-col items-center gap-1 pb-2'>
|
||||
{planBenefits.map((item) => {
|
||||
const content = (
|
||||
<div className='flex items-center gap-2 text-xs text-gray-500'>
|
||||
<Badge dot type='tertiary' />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
if (!item.tooltip) {
|
||||
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>
|
||||
|
||||
<Divider margin={12} />
|
||||
|
||||
Reference in New Issue
Block a user