mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-05-17 11:27:57 +00:00
Implement a new subscription-based billing model alongside existing metered/per-request billing: Backend: - Add subscription plan models (SubscriptionPlan, SubscriptionPlanItem, UserSubscription, etc.) - Implement CRUD APIs for subscription plan management (admin only) - Add user subscription queries with support for multiple active/expired subscriptions - Integrate payment gateways (Stripe, Creem, Epay) for subscription purchases - Implement pre-consume and post-consume billing logic for subscription quota tracking - Add billing preference settings (subscription_first, wallet_first, etc.) - Enhance usage logs with subscription deduction details Frontend - Admin: - Add subscription management page with table view and drawer-based edit form - Match UI/UX style with existing admin pages (redemption codes, users) - Support enabling/disabling plans, configuring payment IDs, and model quotas - Add user subscription binding modal in user management Frontend - Wallet: - Add subscription plans card with current subscription status display - Show all subscriptions (active and expired) with remaining days/usage percentage - Display purchasable plans with pricing cards following SaaS best practices - Extract purchase modal to separate component matching payment confirm modal style - Add skeleton loading states with active animation - Implement billing preference selector in card header - Handle payment gateway availability based on admin configuration Frontend - Usage Logs: - Display subscription deduction details in log entries - Show step-by-step breakdown of subscription usage (pre-consumed, delta, final, remaining) - Add subscription deduction tag for subscription-covered requests
118 lines
4.0 KiB
Go
118 lines
4.0 KiB
Go
package service
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
|
||
"github.com/QuantumNous/new-api/logger"
|
||
"github.com/QuantumNous/new-api/model"
|
||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||
"github.com/QuantumNous/new-api/types"
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
const (
|
||
BillingSourceWallet = "wallet"
|
||
BillingSourceSubscription = "subscription"
|
||
)
|
||
|
||
func normalizeBillingPreference(pref string) string {
|
||
switch pref {
|
||
case "subscription_first", "wallet_first", "subscription_only", "wallet_only":
|
||
return pref
|
||
default:
|
||
return "subscription_first"
|
||
}
|
||
}
|
||
|
||
// PreConsumeBilling decides whether to pre-consume from subscription or wallet based on user preference.
|
||
// It also always pre-consumes token quota in quota units (same as legacy flow).
|
||
func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
|
||
if relayInfo == nil {
|
||
return types.NewError(fmt.Errorf("relayInfo is nil"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
|
||
}
|
||
|
||
pref := normalizeBillingPreference(relayInfo.UserSetting.BillingPreference)
|
||
trySubscription := func() *types.NewAPIError {
|
||
quotaTypes := model.GetModelQuotaTypes(relayInfo.OriginModelName)
|
||
quotaType := 0
|
||
if len(quotaTypes) > 0 {
|
||
quotaType = quotaTypes[0]
|
||
}
|
||
|
||
// For subscription item: per-request consumes 1, per-quota consumes preConsumedQuota quota units.
|
||
subConsume := int64(preConsumedQuota)
|
||
if quotaType == 1 {
|
||
subConsume = 1
|
||
}
|
||
if subConsume <= 0 {
|
||
subConsume = 1
|
||
}
|
||
|
||
// Pre-consume token quota in quota units to keep token limits consistent.
|
||
if preConsumedQuota > 0 {
|
||
if err := PreConsumeTokenQuota(relayInfo, preConsumedQuota); err != nil {
|
||
return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||
}
|
||
}
|
||
|
||
res, err := model.PreConsumeUserSubscription(relayInfo.UserId, relayInfo.OriginModelName, quotaType, subConsume)
|
||
if err != nil {
|
||
// revert token pre-consume when subscription fails
|
||
if preConsumedQuota > 0 && !relayInfo.IsPlayground {
|
||
_ = model.IncreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, preConsumedQuota)
|
||
}
|
||
return types.NewErrorWithStatusCode(fmt.Errorf("订阅额度不足或未配置订阅: %s", err.Error()), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||
}
|
||
|
||
relayInfo.BillingSource = BillingSourceSubscription
|
||
relayInfo.SubscriptionItemId = res.ItemId
|
||
relayInfo.SubscriptionQuotaType = quotaType
|
||
relayInfo.SubscriptionPreConsumed = res.PreConsumed
|
||
relayInfo.SubscriptionPostDelta = 0
|
||
relayInfo.SubscriptionAmountTotal = res.AmountTotal
|
||
relayInfo.SubscriptionAmountUsedAfterPreConsume = res.AmountUsedAfter
|
||
if planInfo, err := model.GetSubscriptionPlanInfoByUserSubscriptionId(res.UserSubscriptionId); err == nil && planInfo != nil {
|
||
relayInfo.SubscriptionPlanId = planInfo.PlanId
|
||
relayInfo.SubscriptionPlanTitle = planInfo.PlanTitle
|
||
}
|
||
relayInfo.FinalPreConsumedQuota = preConsumedQuota
|
||
|
||
logger.LogInfo(c, fmt.Sprintf("用户 %d 使用订阅计费预扣:订阅=%d,token_quota=%d", relayInfo.UserId, res.PreConsumed, preConsumedQuota))
|
||
return nil
|
||
}
|
||
|
||
tryWallet := func() *types.NewAPIError {
|
||
relayInfo.BillingSource = BillingSourceWallet
|
||
relayInfo.SubscriptionItemId = 0
|
||
relayInfo.SubscriptionQuotaType = 0
|
||
relayInfo.SubscriptionPreConsumed = 0
|
||
return PreConsumeQuota(c, preConsumedQuota, relayInfo)
|
||
}
|
||
|
||
switch pref {
|
||
case "subscription_only":
|
||
return trySubscription()
|
||
case "wallet_only":
|
||
return tryWallet()
|
||
case "wallet_first":
|
||
if err := tryWallet(); err != nil {
|
||
// only fallback for insufficient wallet quota
|
||
if err.GetErrorCode() == types.ErrorCodeInsufficientUserQuota {
|
||
return trySubscription()
|
||
}
|
||
return err
|
||
}
|
||
return nil
|
||
case "subscription_first":
|
||
fallthrough
|
||
default:
|
||
if err := trySubscription(); err != nil {
|
||
// fallback only when subscription not available/insufficient
|
||
return tryWallet()
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|