feat: Add subscription upgrade group with auto downgrade

This commit is contained in:
t0ng7u
2026-02-01 02:17:17 +08:00
parent c22ca9cdb3
commit 96caec1626
8 changed files with 455 additions and 51 deletions

View File

@@ -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)
}

View File

@@ -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"`

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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',

View File

@@ -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('请输入秒数') }]}

View File

@@ -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 {

View File

@@ -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} />