feat(account-service): support extend subscription expiration time (#6272)

* move to admin workspace subscription

* support extend subscription expiration time
This commit is contained in:
Jiahui
2025-12-02 14:15:24 +08:00
committed by GitHub
parent e237bac4d5
commit 7153b1e162
7 changed files with 832 additions and 777 deletions
-1
View File
@@ -27,7 +27,6 @@ import (
"github.com/labring/sealos/controllers/account/controllers"
"github.com/labring/sealos/controllers/account/controllers/cache"
"github.com/labring/sealos/controllers/account/controllers/utils"
// devboxv1alpha1 "github.com/labring/sealos/controllers/devbox/api/v1alpha1"
"github.com/labring/sealos/controllers/pkg/database"
"github.com/labring/sealos/controllers/pkg/database/cockroach"
@@ -0,0 +1,578 @@
package api
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/labring/sealos/controllers/pkg/types"
"github.com/labring/sealos/service/account/dao"
"github.com/labring/sealos/service/account/helper"
"github.com/sirupsen/logrus"
)
// @Success 200 {object} gin.H
// @Router /admin/v1alpha1/workspace-subscription/add [post]
func AdminAddWorkspaceSubscription(c *gin.Context) {
req, err := authenticateAndParseAdminRequest(c)
if err != nil {
return
}
if err := setDefaultValues(c, req); err != nil {
return
}
plan, price, err := validatePlanAndPrice(c, req)
if err != nil {
return
}
existingSubscription, err := validateExistingSubscription(c, req)
if err != nil {
return
}
if err := checkSubscriptionQuota(c, req); err != nil {
return
}
// Process subscription transaction
err = processSubscriptionTransaction(req, plan, price, existingSubscription)
if err != nil {
dao.Logger.Errorf("Failed to add workspace subscription via admin interface: %v", err)
SetErrorResp(
c,
http.StatusInternalServerError,
gin.H{"error": fmt.Sprintf("failed to add workspace subscription: %v", err)},
)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf(
"Workspace subscription '%s' added successfully for workspace '%s'",
req.PlanName,
req.Workspace,
),
})
}
// AdminWorkspaceSubscriptionList
// @Summary Admin get workspace subscription list
// @Description Admin interface to get paginated workspace subscription list with filtering options
// @Tags WorkspaceSubscription
// @Accept json
// @Produce json
// @Param req body AdminWorkspaceSubscriptionListReq true "AdminWorkspaceSubscriptionListReq"
// @Success 200 {object} AdminWorkspaceSubscriptionListResp
// @Router /admin/v1alpha1/workspace-subscription/list [post]
func AdminWorkspaceSubscriptionList(c *gin.Context) {
// Authenticate admin request
if err := authenticateAdminRequest(c); err != nil {
SetErrorResp(
c,
http.StatusForbidden,
gin.H{"error": fmt.Sprintf("admin authenticate error: %v", err)},
)
return
}
// Parse request
req, err := helper.ParseAdminWorkspaceSubscriptionListReq(c)
if err != nil {
SetErrorResp(
c,
http.StatusBadRequest,
gin.H{"error": fmt.Sprintf("failed to parse request: %v", err)},
)
return
}
logrus.Infof("Admin getting workspace subscription list: page=%d, size=%d, filters=%+v",
req.PageIndex, req.PageSize, req)
// Build query conditions
conditions := map[string]any{}
if req.Workspace != "" {
conditions["workspace"] = req.Workspace
}
if req.UserUID != uuid.Nil {
conditions["userUid"] = req.UserUID
}
if req.PlanName != "" {
conditions["planName"] = req.PlanName
}
if req.Status != "" {
conditions["status"] = req.Status
}
if req.RegionDomain != "" {
conditions["regionDomain"] = req.RegionDomain
}
// Get subscriptions with pagination
subscriptions, total, err := dao.DBClient.ListWorkspaceSubscriptionsWithPagination(
conditions,
req.PageIndex,
req.PageSize,
)
if err != nil {
SetErrorResp(
c,
http.StatusInternalServerError,
gin.H{"error": fmt.Sprintf("failed to get workspace subscription list: %v", err)},
)
return
}
// Calculate pagination info
totalPages := int(total) / req.PageSize
if int(total)%req.PageSize > 0 {
totalPages++
}
// Format response
type SubscriptionInfo struct {
ID uuid.UUID `json:"id"`
Workspace string `json:"workspace"`
RegionDomain string `json:"regionDomain"`
UserUID uuid.UUID `json:"userUID"`
PlanName string `json:"planName"`
Status string `json:"status"`
PayStatus string `json:"payStatus"`
PayMethod string `json:"payMethod"`
CurrentPeriodStartAt time.Time `json:"currentPeriodStartAt"`
CurrentPeriodEndAt time.Time `json:"currentPeriodEndAt"`
CreateAt time.Time `json:"createAt"`
ExpireAt *time.Time `json:"expireAt"`
}
subscriptionInfos := make([]SubscriptionInfo, len(subscriptions))
for i, sub := range subscriptions {
subscriptionInfos[i] = SubscriptionInfo{
ID: sub.ID,
Workspace: sub.Workspace,
RegionDomain: sub.RegionDomain,
UserUID: sub.UserUID,
PlanName: sub.PlanName,
Status: string(sub.Status),
PayStatus: string(sub.PayStatus),
PayMethod: string(sub.PayMethod),
CurrentPeriodStartAt: sub.CurrentPeriodStartAt,
CurrentPeriodEndAt: sub.CurrentPeriodEndAt,
CreateAt: sub.CreateAt,
ExpireAt: sub.ExpireAt,
}
}
resp := gin.H{
"subscriptions": subscriptionInfos,
"pagination": gin.H{
"pageIndex": req.PageIndex,
"pageSize": req.PageSize,
"totalRecords": total,
"totalPages": totalPages,
},
}
c.JSON(http.StatusOK, resp)
}
// AdminSubscriptionPlans
// @Summary Admin get subscription plans
// @Description Admin interface to get all available subscription plans
// @Tags WorkspaceSubscription
// @Accept json
// @Produce json
// @Param req body AdminSubscriptionPlansReq true "AdminSubscriptionPlansReq"
// @Success 200 {object} AdminSubscriptionPlansResp
// @Router /admin/v1alpha1/subscription-plans [post]
func AdminSubscriptionPlans(c *gin.Context) {
// Authenticate admin request
if err := authenticateAdminRequest(c); err != nil {
SetErrorResp(
c,
http.StatusForbidden,
gin.H{"error": fmt.Sprintf("admin authenticate error: %v", err)},
)
return
}
// Parse request
req, err := helper.ParseAdminSubscriptionPlansReq(c)
if err != nil {
SetErrorResp(
c,
http.StatusBadRequest,
gin.H{"error": fmt.Sprintf("failed to parse request: %v", err)},
)
return
}
logrus.Infof("Admin getting subscription plans: includeInactive=%v, planType=%s",
req.IncludeInactive, req.PlanType)
// Get subscription plans
plans, err := dao.DBClient.GetWorkspaceSubscriptionPlanList()
if err != nil {
SetErrorResp(
c,
http.StatusInternalServerError,
gin.H{"error": fmt.Sprintf("failed to get subscription plans: %v", err)},
)
return
}
// Filter plans based on request parameters
filteredPlans := []types.WorkspaceSubscriptionPlan{}
for _, plan := range plans {
// Filter by plan type if specified (check tags for type classification)
if req.PlanType != "" {
hasType := false
for _, tag := range plan.Tags {
if tag == req.PlanType {
hasType = true
break
}
}
if !hasType {
continue
}
}
// For now, include all plans since there's no explicit status field
// The includeInactive parameter can be used later if status tracking is added
filteredPlans = append(filteredPlans, plan)
}
// Format response with pricing information
type PlanPriceInfo struct {
BillingCycle string `json:"billingCycle"`
Price int64 `json:"price"`
Currency string `json:"currency"`
}
type PlanInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Order int `json:"order"`
Tags []string `json:"tags"`
Prices []PlanPriceInfo `json:"prices"`
Traffic int64 `json:"traffic"`
AIQuota int64 `json:"aiQuota"`
MaxResources string `json:"maxResources"`
MaxSeats int `json:"maxSeats"`
}
planInfos := make([]PlanInfo, len(filteredPlans))
for i, plan := range filteredPlans {
prices := make([]PlanPriceInfo, len(plan.Prices))
for j, price := range plan.Prices {
prices[j] = PlanPriceInfo{
BillingCycle: string(price.BillingCycle),
Price: price.Price,
Currency: "USD", // Default currency
}
}
planInfos[i] = PlanInfo{
ID: plan.ID.String(),
Name: plan.Name,
Description: plan.Description,
Order: plan.Order,
Tags: plan.Tags,
Prices: prices,
Traffic: plan.Traffic,
AIQuota: plan.AIQuota,
MaxResources: plan.MaxResources,
MaxSeats: plan.MaxSeats,
}
}
resp := gin.H{
"plans": planInfos,
"total": len(planInfos),
}
c.JSON(http.StatusOK, resp)
}
// AdminWorkspaceSubscriptionListGET
// @Summary Admin get workspace subscription list (GET)
// @Description Admin interface to get paginated workspace subscription list with filtering options using GET method
// @Tags WorkspaceSubscription
// @Accept json
// @Produce json
// @Param pageIndex query int false "Page index (0-based)" example(0)
// @Param pageSize query int false "Page size (optional, defaults to 10)" example(10)
// @Param workspace query string false "Filter by workspace name" example("ns-8gmgq0jn")
// @Param userUID query string false "Filter by user ID" example("36ca5ee6-7b6e-4c15-922b-e861b3fbc061")
// @Param planName query string false "Filter by plan name" example("Hobby")
// @Param status query string false "Filter by subscription status" example("NORMAL")
// @Param regionDomain query string false "Filter by region domain" example("192.168.10.35.nip.io")
// @Success 200 {object} AdminWorkspaceSubscriptionListResp
// @Router /admin/v1alpha1/workspace-subscription/list [get]
func AdminWorkspaceSubscriptionListGET(c *gin.Context) {
// Authenticate admin request
if err := authenticateAdminRequest(c); err != nil {
SetErrorResp(
c,
http.StatusForbidden,
gin.H{"error": fmt.Sprintf("admin authenticate error: %v", err)},
)
return
}
// Parse query parameters
req := &helper.AdminWorkspaceSubscriptionListReq{}
// Parse pagination parameters
if pageIndexStr := c.Query("pageIndex"); pageIndexStr != "" {
if pageIndex, err := strconv.Atoi(pageIndexStr); err == nil {
req.PageIndex = pageIndex
}
}
if pageSizeStr := c.Query("pageSize"); pageSizeStr != "" {
if pageSize, err := strconv.Atoi(pageSizeStr); err == nil {
req.PageSize = pageSize
}
}
// Parse filter parameters
req.Workspace = c.Query("workspace")
req.PlanName = c.Query("planName")
req.Status = c.Query("status")
req.RegionDomain = c.Query("regionDomain")
// Parse userUID parameter
if userUIDStr := c.Query("userUID"); userUIDStr != "" {
if userUID, err := uuid.Parse(userUIDStr); err == nil {
req.UserUID = userUID
}
}
// Set default values
if req.PageIndex < 0 {
req.PageIndex = 0
}
if req.PageSize <= 0 {
req.PageSize = 10
}
if req.PageSize > 100 {
req.PageSize = 100 // Limit max page size
}
logrus.Infof("Admin getting workspace subscription list (GET): page=%d, size=%d, filters=%+v",
req.PageIndex, req.PageSize, req)
// Build query conditions
conditions := map[string]any{}
if req.Workspace != "" {
conditions["workspace"] = req.Workspace
}
if req.UserUID != uuid.Nil {
conditions["userUid"] = req.UserUID
}
if req.PlanName != "" {
conditions["planName"] = req.PlanName
}
if req.Status != "" {
conditions["status"] = req.Status
}
if req.RegionDomain != "" {
conditions["regionDomain"] = req.RegionDomain
}
// Get subscriptions with pagination
subscriptions, total, err := dao.DBClient.ListWorkspaceSubscriptionsWithPagination(
conditions,
req.PageIndex,
req.PageSize,
)
if err != nil {
SetErrorResp(
c,
http.StatusInternalServerError,
gin.H{"error": fmt.Sprintf("failed to get workspace subscription list: %v", err)},
)
return
}
// Calculate pagination info
totalPages := int(total) / req.PageSize
if int(total)%req.PageSize > 0 {
totalPages++
}
// Format response
type SubscriptionInfo struct {
ID uuid.UUID `json:"id"`
Workspace string `json:"workspace"`
RegionDomain string `json:"regionDomain"`
UserUID uuid.UUID `json:"userUID"`
PlanName string `json:"planName"`
Status string `json:"status"`
PayStatus string `json:"payStatus"`
PayMethod string `json:"payMethod"`
CurrentPeriodStartAt time.Time `json:"currentPeriodStartAt"`
CurrentPeriodEndAt time.Time `json:"currentPeriodEndAt"`
CreateAt time.Time `json:"createAt"`
ExpireAt *time.Time `json:"expireAt"`
}
subscriptionInfos := make([]SubscriptionInfo, len(subscriptions))
for i, sub := range subscriptions {
subscriptionInfos[i] = SubscriptionInfo{
ID: sub.ID,
Workspace: sub.Workspace,
RegionDomain: sub.RegionDomain,
UserUID: sub.UserUID,
PlanName: sub.PlanName,
Status: string(sub.Status),
PayStatus: string(sub.PayStatus),
PayMethod: string(sub.PayMethod),
CurrentPeriodStartAt: sub.CurrentPeriodStartAt,
CurrentPeriodEndAt: sub.CurrentPeriodEndAt,
CreateAt: sub.CreateAt,
ExpireAt: sub.ExpireAt,
}
}
resp := gin.H{
"subscriptions": subscriptionInfos,
"pagination": gin.H{
"pageIndex": req.PageIndex,
"pageSize": req.PageSize,
"totalRecords": total,
"totalPages": totalPages,
},
}
c.JSON(http.StatusOK, resp)
}
// AdminSubscriptionPlansGET
// @Summary Admin get subscription plans (GET)
// @Description Admin interface to get all available subscription plans using GET method
// @Tags WorkspaceSubscription
// @Accept json
// @Produce json
// @Param includeInactive query bool false "Include inactive plans" example(false)
// @Param planType query string false "Filter by plan type" example("workspace")
// @Success 200 {object} AdminSubscriptionPlansResp
// @Router /admin/v1alpha1/subscription-plans [get]
func AdminSubscriptionPlansGET(c *gin.Context) {
// Authenticate admin request
if err := authenticateAdminRequest(c); err != nil {
SetErrorResp(
c,
http.StatusForbidden,
gin.H{"error": fmt.Sprintf("admin authenticate error: %v", err)},
)
return
}
// Parse query parameters
req := &helper.AdminSubscriptionPlansReq{}
if includeInactiveStr := c.Query("includeInactive"); includeInactiveStr != "" {
if includeInactive, err := strconv.ParseBool(includeInactiveStr); err == nil {
req.IncludeInactive = includeInactive
}
}
req.PlanType = c.Query("planType")
logrus.Infof("Admin getting subscription plans (GET): includeInactive=%v, planType=%s",
req.IncludeInactive, req.PlanType)
// Get subscription plans
plans, err := dao.DBClient.GetWorkspaceSubscriptionPlanList()
if err != nil {
SetErrorResp(
c,
http.StatusInternalServerError,
gin.H{"error": fmt.Sprintf("failed to get subscription plans: %v", err)},
)
return
}
// Filter plans based on request parameters
filteredPlans := []types.WorkspaceSubscriptionPlan{}
for _, plan := range plans {
// Filter by plan type if specified (check tags for type classification)
if req.PlanType != "" {
hasType := false
for _, tag := range plan.Tags {
if tag == req.PlanType {
hasType = true
break
}
}
if !hasType {
continue
}
}
// For now, include all plans since there's no explicit status field
// The includeInactive parameter can be used later if status tracking is added
filteredPlans = append(filteredPlans, plan)
}
// Format response with pricing information
type PlanPriceInfo struct {
BillingCycle string `json:"billingCycle"`
Price int64 `json:"price"`
Currency string `json:"currency"`
}
type PlanInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Order int `json:"order"`
Tags []string `json:"tags"`
Prices []PlanPriceInfo `json:"prices"`
Traffic int64 `json:"traffic"`
AIQuota int64 `json:"aiQuota"`
MaxResources string `json:"maxResources"`
MaxSeats int `json:"maxSeats"`
}
planInfos := make([]PlanInfo, len(filteredPlans))
for i, plan := range filteredPlans {
prices := make([]PlanPriceInfo, len(plan.Prices))
for j, price := range plan.Prices {
prices[j] = PlanPriceInfo{
BillingCycle: string(price.BillingCycle),
Price: price.Price,
Currency: "USD", // Default currency
}
}
planInfos[i] = PlanInfo{
ID: plan.ID.String(),
Name: plan.Name,
Description: plan.Description,
Order: plan.Order,
Tags: plan.Tags,
Prices: prices,
Traffic: plan.Traffic,
AIQuota: plan.AIQuota,
MaxResources: plan.MaxResources,
MaxSeats: plan.MaxSeats,
}
}
resp := gin.H{
"plans": planInfos,
"total": len(planInfos),
}
c.JSON(http.StatusOK, resp)
}
+58 -605
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"
@@ -158,10 +157,14 @@ func DeleteWorkspaceSubscription(c *gin.Context) {
sub, err := services.StripeServiceInstance.CancelSubscription(
subscription.Stripe.SubscriptionID)
if err != nil {
return fmt.Errorf("failed to cancel Stripe subscription %s: %v", subscription.Stripe.SubscriptionID, err)
return fmt.Errorf(
"failed to cancel Stripe subscription %s: %w",
subscription.Stripe.SubscriptionID,
err,
)
}
if sub == nil {
return fmt.Errorf("stripe subscription cancel failed with nil subscription")
return errors.New("stripe subscription cancel failed with nil subscription")
}
}
}
@@ -3046,30 +3049,53 @@ func createOrUpdateWorkspaceSubscription(
var workspaceSubscription *types.WorkspaceSubscription
if existingSubscription != nil {
workspaceSubscription = existingSubscription
workspaceSubscription.PlanName = req.PlanName
workspaceSubscription.PayStatus = types.SubscriptionPayStatusNoNeed
workspaceSubscription.TrafficStatus = types.WorkspaceTrafficStatusActive
workspaceSubscription.Status = types.SubscriptionStatusNormal
workspaceSubscription.PayMethod = types.PaymentMethodBalance // Admin operations use balance payment
// Update period for renewals or extensions
if req.Operator == types.SubscriptionTransactionTypeRenewed ||
req.Operator == types.SubscriptionTransactionTypeUpgraded {
// Parse the period and add to current end time
periodDuration, err := types.ParsePeriod(req.Period)
if err != nil {
// Fallback to monthly if parsing fails
workspaceSubscription.CurrentPeriodStartAt = now
workspaceSubscription.CurrentPeriodEndAt = now.AddDate(0, 1, 0)
workspaceSubscription.ExpireAt = stripe.Time(
workspaceSubscription.CurrentPeriodEndAt,
)
// Parse period duration
periodDuration, err := types.ParsePeriod(req.Period)
if err != nil {
// Fallback to monthly if parsing fails
periodDuration = 30 * 24 * time.Hour
}
// Handle different operators
switch req.Operator {
case types.SubscriptionTransactionTypeRenewed:
// For renewal: only extend ExpireAt, don't modify current period
// The processor will handle period renewal when CurrentPeriodEndAt approaches
if workspaceSubscription.ExpireAt == nil {
expireTime := existingSubscription.CurrentPeriodEndAt.Add(periodDuration)
workspaceSubscription.ExpireAt = &expireTime
} else {
// Extend from current end time
workspaceSubscription.CurrentPeriodStartAt = existingSubscription.CurrentPeriodEndAt
workspaceSubscription.CurrentPeriodEndAt = existingSubscription.CurrentPeriodEndAt.Add(periodDuration)
workspaceSubscription.ExpireAt = stripe.Time(workspaceSubscription.CurrentPeriodEndAt)
expireTime := workspaceSubscription.ExpireAt.Add(periodDuration)
workspaceSubscription.ExpireAt = &expireTime
}
logrus.Infof(
"Renewal: Extended ExpireAt to %s, current period unchanged (ends at %s)",
workspaceSubscription.ExpireAt.Format(time.RFC3339),
workspaceSubscription.CurrentPeriodEndAt.Format(time.RFC3339),
)
case types.SubscriptionTransactionTypeUpgraded, types.SubscriptionTransactionTypeDowngraded:
// For upgrade/downgrade: immediately update current period and plan
workspaceSubscription.PlanName = req.PlanName
workspaceSubscription.CurrentPeriodStartAt = now
workspaceSubscription.CurrentPeriodEndAt = now.Add(periodDuration)
// Set ExpireAt to the new current period end if not set, or extend it
if workspaceSubscription.ExpireAt == nil ||
workspaceSubscription.ExpireAt.Before(workspaceSubscription.CurrentPeriodEndAt) {
workspaceSubscription.ExpireAt = &workspaceSubscription.CurrentPeriodEndAt
}
logrus.Infof(
"Upgrade/Downgrade: Updated current period to %s - %s, plan=%s",
workspaceSubscription.CurrentPeriodStartAt.Format(time.RFC3339),
workspaceSubscription.CurrentPeriodEndAt.Format(time.RFC3339),
req.PlanName,
)
}
} else {
// Create new subscription - parse period for correct end time
@@ -3110,25 +3136,18 @@ func addTrafficAndAIPackages(
existingSubscription, workspaceSubscription *types.WorkspaceSubscription,
transactionID string,
) error {
// For renewal operations, check if current period is still valid
// If current period hasn't expired, don't add traffic/AI packages for renewals
if req.Operator == types.SubscriptionTransactionTypeRenewed &&
existingSubscription != nil &&
existingSubscription.CurrentPeriodEndAt.After(time.Now()) {
// For renewal operations, skip adding packages - they will be handled by the processor
// when the current period is about to end
if req.Operator == types.SubscriptionTransactionTypeRenewed {
logrus.Infof(
"Skipping traffic/AI package addition for renewal: current period still valid until %s",
existingSubscription.CurrentPeriodEndAt.Format(time.DateTime),
"Skipping traffic/AI package addition for renewal: will be handled by processor before period end",
)
return nil
}
// For upgrade/downgrade/create operations, add packages immediately
// Add traffic package
if plan.Traffic > 0 && req.Operator != types.SubscriptionTransactionTypeDowngraded {
period, err := types.ParsePeriod(req.Period)
if err != nil {
return fmt.Errorf("invalid subscription period: %w", err)
}
// Calculate additional traffic for upgrades
additionalTraffic := plan.Traffic
if req.Operator == types.SubscriptionTransactionTypeUpgraded &&
@@ -3147,12 +3166,12 @@ func addTrafficAndAIPackages(
}
if additionalTraffic > 0 {
err = helper.AddTrafficPackage(
err := helper.AddTrafficPackage(
tx,
dao.K8sManager.GetClient(),
workspaceSubscription,
plan,
time.Now().Add(period),
workspaceSubscription.CurrentPeriodEndAt,
types.WorkspaceTrafficFromWorkspaceSubscription,
transactionID,
)
@@ -3164,11 +3183,6 @@ func addTrafficAndAIPackages(
// Add AI quota package
if plan.AIQuota > 0 && req.Operator != types.SubscriptionTransactionTypeDowngraded {
period, err := types.ParsePeriod(req.Period)
if err != nil {
return fmt.Errorf("invalid subscription period: %w", err)
}
// Calculate additional AI quota for upgrades
additionalAIQuota := plan.AIQuota
if req.Operator == types.SubscriptionTransactionTypeUpgraded &&
@@ -3187,11 +3201,11 @@ func addTrafficAndAIPackages(
}
if additionalAIQuota > 0 {
err = cockroach.AddWorkspaceSubscriptionAIQuotaPackage(
err := cockroach.AddWorkspaceSubscriptionAIQuotaPackage(
tx,
workspaceSubscription.ID,
additionalAIQuota,
time.Now().Add(period),
workspaceSubscription.CurrentPeriodEndAt,
types.PKGFromWorkspaceSubscription,
transactionID,
)
@@ -3233,7 +3247,8 @@ func processSubscriptionTransaction(
return fmt.Errorf("failed to save workspace subscription: %w", err)
}
// Update resource quota for creation or upgrade
// Update resource quota for creation, upgrade or downgrade (not for renewal)
// Renewal doesn't change the current period plan, so no quota update needed
if req.Operator != types.SubscriptionTransactionTypeRenewed {
if err := updateWorkspaceSubscriptionQuota(req.PlanName, workspaceSubscription.Workspace); err != nil {
return fmt.Errorf("failed to update workspace subscription quota: %w", err)
@@ -3241,6 +3256,7 @@ func processSubscriptionTransaction(
}
// Add traffic and AI packages
// For renewal, this will be skipped and handled by the processor
if err := addTrafficAndAIPackages(tx, req, plan, existingSubscription, workspaceSubscription, transaction.ID.String()); err != nil {
return err
}
@@ -3265,54 +3281,6 @@ func processSubscriptionTransaction(
})
}
// @Success 200 {object} gin.H
// @Router /admin/v1alpha1/workspace-subscription/add [post]
func AdminAddWorkspaceSubscription(c *gin.Context) {
req, err := authenticateAndParseAdminRequest(c)
if err != nil {
return
}
if err := setDefaultValues(c, req); err != nil {
return
}
plan, price, err := validatePlanAndPrice(c, req)
if err != nil {
return
}
existingSubscription, err := validateExistingSubscription(c, req)
if err != nil {
return
}
if err := checkSubscriptionQuota(c, req); err != nil {
return
}
// Process subscription transaction
err = processSubscriptionTransaction(req, plan, price, existingSubscription)
if err != nil {
dao.Logger.Errorf("Failed to add workspace subscription via admin interface: %v", err)
SetErrorResp(
c,
http.StatusInternalServerError,
gin.H{"error": fmt.Sprintf("failed to add workspace subscription: %v", err)},
)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf(
"Workspace subscription '%s' added successfully for workspace '%s'",
req.PlanName,
req.Workspace,
),
})
}
// GetWorkspaceSubscriptionPlans
// @Summary Get workspace subscription plans by namespaces
// @Description Get subscription plan names for multiple namespaces, returning "PAYG" for non-subscribed workspaces
@@ -3388,518 +3356,3 @@ func GetWorkspaceSubscriptionPlans(c *gin.Context) {
Plans: plans,
})
}
// AdminWorkspaceSubscriptionList
// @Summary Admin get workspace subscription list
// @Description Admin interface to get paginated workspace subscription list with filtering options
// @Tags WorkspaceSubscription
// @Accept json
// @Produce json
// @Param req body AdminWorkspaceSubscriptionListReq true "AdminWorkspaceSubscriptionListReq"
// @Success 200 {object} AdminWorkspaceSubscriptionListResp
// @Router /admin/v1alpha1/workspace-subscription/list [post]
func AdminWorkspaceSubscriptionList(c *gin.Context) {
// Authenticate admin request
if err := authenticateAdminRequest(c); err != nil {
SetErrorResp(
c,
http.StatusForbidden,
gin.H{"error": fmt.Sprintf("admin authenticate error: %v", err)},
)
return
}
// Parse request
req, err := helper.ParseAdminWorkspaceSubscriptionListReq(c)
if err != nil {
SetErrorResp(
c,
http.StatusBadRequest,
gin.H{"error": fmt.Sprintf("failed to parse request: %v", err)},
)
return
}
logrus.Infof("Admin getting workspace subscription list: page=%d, size=%d, filters=%+v",
req.PageIndex, req.PageSize, req)
// Build query conditions
conditions := map[string]any{}
if req.Workspace != "" {
conditions["workspace"] = req.Workspace
}
if req.UserUID != uuid.Nil {
conditions["userUid"] = req.UserUID
}
if req.PlanName != "" {
conditions["planName"] = req.PlanName
}
if req.Status != "" {
conditions["status"] = req.Status
}
if req.RegionDomain != "" {
conditions["regionDomain"] = req.RegionDomain
}
// Get subscriptions with pagination
subscriptions, total, err := dao.DBClient.ListWorkspaceSubscriptionsWithPagination(
conditions,
req.PageIndex,
req.PageSize,
)
if err != nil {
SetErrorResp(
c,
http.StatusInternalServerError,
gin.H{"error": fmt.Sprintf("failed to get workspace subscription list: %v", err)},
)
return
}
// Calculate pagination info
totalPages := int(total) / req.PageSize
if int(total)%req.PageSize > 0 {
totalPages++
}
// Format response
type SubscriptionInfo struct {
ID uuid.UUID `json:"id"`
Workspace string `json:"workspace"`
RegionDomain string `json:"regionDomain"`
UserUID uuid.UUID `json:"userUID"`
PlanName string `json:"planName"`
Status string `json:"status"`
PayStatus string `json:"payStatus"`
PayMethod string `json:"payMethod"`
CurrentPeriodStartAt time.Time `json:"currentPeriodStartAt"`
CurrentPeriodEndAt time.Time `json:"currentPeriodEndAt"`
CreateAt time.Time `json:"createAt"`
ExpireAt *time.Time `json:"expireAt"`
}
subscriptionInfos := make([]SubscriptionInfo, len(subscriptions))
for i, sub := range subscriptions {
subscriptionInfos[i] = SubscriptionInfo{
ID: sub.ID,
Workspace: sub.Workspace,
RegionDomain: sub.RegionDomain,
UserUID: sub.UserUID,
PlanName: sub.PlanName,
Status: string(sub.Status),
PayStatus: string(sub.PayStatus),
PayMethod: string(sub.PayMethod),
CurrentPeriodStartAt: sub.CurrentPeriodStartAt,
CurrentPeriodEndAt: sub.CurrentPeriodEndAt,
CreateAt: sub.CreateAt,
ExpireAt: sub.ExpireAt,
}
}
resp := gin.H{
"subscriptions": subscriptionInfos,
"pagination": gin.H{
"pageIndex": req.PageIndex,
"pageSize": req.PageSize,
"totalRecords": total,
"totalPages": totalPages,
},
}
c.JSON(http.StatusOK, resp)
}
// AdminSubscriptionPlans
// @Summary Admin get subscription plans
// @Description Admin interface to get all available subscription plans
// @Tags WorkspaceSubscription
// @Accept json
// @Produce json
// @Param req body AdminSubscriptionPlansReq true "AdminSubscriptionPlansReq"
// @Success 200 {object} AdminSubscriptionPlansResp
// @Router /admin/v1alpha1/subscription-plans [post]
func AdminSubscriptionPlans(c *gin.Context) {
// Authenticate admin request
if err := authenticateAdminRequest(c); err != nil {
SetErrorResp(
c,
http.StatusForbidden,
gin.H{"error": fmt.Sprintf("admin authenticate error: %v", err)},
)
return
}
// Parse request
req, err := helper.ParseAdminSubscriptionPlansReq(c)
if err != nil {
SetErrorResp(
c,
http.StatusBadRequest,
gin.H{"error": fmt.Sprintf("failed to parse request: %v", err)},
)
return
}
logrus.Infof("Admin getting subscription plans: includeInactive=%v, planType=%s",
req.IncludeInactive, req.PlanType)
// Get subscription plans
plans, err := dao.DBClient.GetWorkspaceSubscriptionPlanList()
if err != nil {
SetErrorResp(
c,
http.StatusInternalServerError,
gin.H{"error": fmt.Sprintf("failed to get subscription plans: %v", err)},
)
return
}
// Filter plans based on request parameters
filteredPlans := []types.WorkspaceSubscriptionPlan{}
for _, plan := range plans {
// Filter by plan type if specified (check tags for type classification)
if req.PlanType != "" {
hasType := false
for _, tag := range plan.Tags {
if tag == req.PlanType {
hasType = true
break
}
}
if !hasType {
continue
}
}
// For now, include all plans since there's no explicit status field
// The includeInactive parameter can be used later if status tracking is added
filteredPlans = append(filteredPlans, plan)
}
// Format response with pricing information
type PlanPriceInfo struct {
BillingCycle string `json:"billingCycle"`
Price int64 `json:"price"`
Currency string `json:"currency"`
}
type PlanInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Order int `json:"order"`
Tags []string `json:"tags"`
Prices []PlanPriceInfo `json:"prices"`
Traffic int64 `json:"traffic"`
AIQuota int64 `json:"aiQuota"`
MaxResources string `json:"maxResources"`
MaxSeats int `json:"maxSeats"`
}
planInfos := make([]PlanInfo, len(filteredPlans))
for i, plan := range filteredPlans {
prices := make([]PlanPriceInfo, len(plan.Prices))
for j, price := range plan.Prices {
prices[j] = PlanPriceInfo{
BillingCycle: string(price.BillingCycle),
Price: price.Price,
Currency: "USD", // Default currency
}
}
planInfos[i] = PlanInfo{
ID: plan.ID.String(),
Name: plan.Name,
Description: plan.Description,
Order: plan.Order,
Tags: plan.Tags,
Prices: prices,
Traffic: plan.Traffic,
AIQuota: plan.AIQuota,
MaxResources: plan.MaxResources,
MaxSeats: plan.MaxSeats,
}
}
resp := gin.H{
"plans": planInfos,
"total": len(planInfos),
}
c.JSON(http.StatusOK, resp)
}
// AdminWorkspaceSubscriptionListGET
// @Summary Admin get workspace subscription list (GET)
// @Description Admin interface to get paginated workspace subscription list with filtering options using GET method
// @Tags WorkspaceSubscription
// @Accept json
// @Produce json
// @Param pageIndex query int false "Page index (0-based)" example(0)
// @Param pageSize query int false "Page size (optional, defaults to 10)" example(10)
// @Param workspace query string false "Filter by workspace name" example("ns-8gmgq0jn")
// @Param userUID query string false "Filter by user ID" example("36ca5ee6-7b6e-4c15-922b-e861b3fbc061")
// @Param planName query string false "Filter by plan name" example("Hobby")
// @Param status query string false "Filter by subscription status" example("NORMAL")
// @Param regionDomain query string false "Filter by region domain" example("192.168.10.35.nip.io")
// @Success 200 {object} AdminWorkspaceSubscriptionListResp
// @Router /admin/v1alpha1/workspace-subscription/list [get]
func AdminWorkspaceSubscriptionListGET(c *gin.Context) {
// Authenticate admin request
if err := authenticateAdminRequest(c); err != nil {
SetErrorResp(
c,
http.StatusForbidden,
gin.H{"error": fmt.Sprintf("admin authenticate error: %v", err)},
)
return
}
// Parse query parameters
req := &helper.AdminWorkspaceSubscriptionListReq{}
// Parse pagination parameters
if pageIndexStr := c.Query("pageIndex"); pageIndexStr != "" {
if pageIndex, err := strconv.Atoi(pageIndexStr); err == nil {
req.PageIndex = pageIndex
}
}
if pageSizeStr := c.Query("pageSize"); pageSizeStr != "" {
if pageSize, err := strconv.Atoi(pageSizeStr); err == nil {
req.PageSize = pageSize
}
}
// Parse filter parameters
req.Workspace = c.Query("workspace")
req.PlanName = c.Query("planName")
req.Status = c.Query("status")
req.RegionDomain = c.Query("regionDomain")
// Parse userUID parameter
if userUIDStr := c.Query("userUID"); userUIDStr != "" {
if userUID, err := uuid.Parse(userUIDStr); err == nil {
req.UserUID = userUID
}
}
// Set default values
if req.PageIndex < 0 {
req.PageIndex = 0
}
if req.PageSize <= 0 {
req.PageSize = 10
}
if req.PageSize > 100 {
req.PageSize = 100 // Limit max page size
}
logrus.Infof("Admin getting workspace subscription list (GET): page=%d, size=%d, filters=%+v",
req.PageIndex, req.PageSize, req)
// Build query conditions
conditions := map[string]any{}
if req.Workspace != "" {
conditions["workspace"] = req.Workspace
}
if req.UserUID != uuid.Nil {
conditions["userUid"] = req.UserUID
}
if req.PlanName != "" {
conditions["planName"] = req.PlanName
}
if req.Status != "" {
conditions["status"] = req.Status
}
if req.RegionDomain != "" {
conditions["regionDomain"] = req.RegionDomain
}
// Get subscriptions with pagination
subscriptions, total, err := dao.DBClient.ListWorkspaceSubscriptionsWithPagination(
conditions,
req.PageIndex,
req.PageSize,
)
if err != nil {
SetErrorResp(
c,
http.StatusInternalServerError,
gin.H{"error": fmt.Sprintf("failed to get workspace subscription list: %v", err)},
)
return
}
// Calculate pagination info
totalPages := int(total) / req.PageSize
if int(total)%req.PageSize > 0 {
totalPages++
}
// Format response
type SubscriptionInfo struct {
ID uuid.UUID `json:"id"`
Workspace string `json:"workspace"`
RegionDomain string `json:"regionDomain"`
UserUID uuid.UUID `json:"userUID"`
PlanName string `json:"planName"`
Status string `json:"status"`
PayStatus string `json:"payStatus"`
PayMethod string `json:"payMethod"`
CurrentPeriodStartAt time.Time `json:"currentPeriodStartAt"`
CurrentPeriodEndAt time.Time `json:"currentPeriodEndAt"`
CreateAt time.Time `json:"createAt"`
ExpireAt *time.Time `json:"expireAt"`
}
subscriptionInfos := make([]SubscriptionInfo, len(subscriptions))
for i, sub := range subscriptions {
subscriptionInfos[i] = SubscriptionInfo{
ID: sub.ID,
Workspace: sub.Workspace,
RegionDomain: sub.RegionDomain,
UserUID: sub.UserUID,
PlanName: sub.PlanName,
Status: string(sub.Status),
PayStatus: string(sub.PayStatus),
PayMethod: string(sub.PayMethod),
CurrentPeriodStartAt: sub.CurrentPeriodStartAt,
CurrentPeriodEndAt: sub.CurrentPeriodEndAt,
CreateAt: sub.CreateAt,
ExpireAt: sub.ExpireAt,
}
}
resp := gin.H{
"subscriptions": subscriptionInfos,
"pagination": gin.H{
"pageIndex": req.PageIndex,
"pageSize": req.PageSize,
"totalRecords": total,
"totalPages": totalPages,
},
}
c.JSON(http.StatusOK, resp)
}
// AdminSubscriptionPlansGET
// @Summary Admin get subscription plans (GET)
// @Description Admin interface to get all available subscription plans using GET method
// @Tags WorkspaceSubscription
// @Accept json
// @Produce json
// @Param includeInactive query bool false "Include inactive plans" example(false)
// @Param planType query string false "Filter by plan type" example("workspace")
// @Success 200 {object} AdminSubscriptionPlansResp
// @Router /admin/v1alpha1/subscription-plans [get]
func AdminSubscriptionPlansGET(c *gin.Context) {
// Authenticate admin request
if err := authenticateAdminRequest(c); err != nil {
SetErrorResp(
c,
http.StatusForbidden,
gin.H{"error": fmt.Sprintf("admin authenticate error: %v", err)},
)
return
}
// Parse query parameters
req := &helper.AdminSubscriptionPlansReq{}
if includeInactiveStr := c.Query("includeInactive"); includeInactiveStr != "" {
if includeInactive, err := strconv.ParseBool(includeInactiveStr); err == nil {
req.IncludeInactive = includeInactive
}
}
req.PlanType = c.Query("planType")
logrus.Infof("Admin getting subscription plans (GET): includeInactive=%v, planType=%s",
req.IncludeInactive, req.PlanType)
// Get subscription plans
plans, err := dao.DBClient.GetWorkspaceSubscriptionPlanList()
if err != nil {
SetErrorResp(
c,
http.StatusInternalServerError,
gin.H{"error": fmt.Sprintf("failed to get subscription plans: %v", err)},
)
return
}
// Filter plans based on request parameters
filteredPlans := []types.WorkspaceSubscriptionPlan{}
for _, plan := range plans {
// Filter by plan type if specified (check tags for type classification)
if req.PlanType != "" {
hasType := false
for _, tag := range plan.Tags {
if tag == req.PlanType {
hasType = true
break
}
}
if !hasType {
continue
}
}
// For now, include all plans since there's no explicit status field
// The includeInactive parameter can be used later if status tracking is added
filteredPlans = append(filteredPlans, plan)
}
// Format response with pricing information
type PlanPriceInfo struct {
BillingCycle string `json:"billingCycle"`
Price int64 `json:"price"`
Currency string `json:"currency"`
}
type PlanInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Order int `json:"order"`
Tags []string `json:"tags"`
Prices []PlanPriceInfo `json:"prices"`
Traffic int64 `json:"traffic"`
AIQuota int64 `json:"aiQuota"`
MaxResources string `json:"maxResources"`
MaxSeats int `json:"maxSeats"`
}
planInfos := make([]PlanInfo, len(filteredPlans))
for i, plan := range filteredPlans {
prices := make([]PlanPriceInfo, len(plan.Prices))
for j, price := range plan.Prices {
prices[j] = PlanPriceInfo{
BillingCycle: string(price.BillingCycle),
Price: price.Price,
Currency: "USD", // Default currency
}
}
planInfos[i] = PlanInfo{
ID: plan.ID.String(),
Name: plan.Name,
Description: plan.Description,
Order: plan.Order,
Tags: plan.Tags,
Prices: prices,
Traffic: plan.Traffic,
AIQuota: plan.AIQuota,
MaxResources: plan.MaxResources,
MaxSeats: plan.MaxSeats,
}
}
resp := gin.H{
"plans": planInfos,
"total": len(planInfos),
}
c.JSON(http.StatusOK, resp)
}
@@ -610,37 +610,46 @@ func CheckQuota(ctx context.Context, workspace, planName string) (bool, error) {
}
// processExpiredBalanceSubscriptions 处理已到期的余额支付订阅
// 1. The current cycle is about to end (within 20 minutes) and the expiration time is longer than the end time of the current cycle - renewal is required
// 2. The situation where the current cycle has ended and needs to be renewed
func (wsp *WorkspaceSubscriptionProcessor) processExpiredBalanceSubscriptions(
ctx context.Context,
) (int, error) {
var expiredSubscriptions []types.WorkspaceSubscription
var subscriptionsToProcess []types.WorkspaceSubscription
now := time.Now().UTC()
renewalThreshold := now.Add(20 * time.Minute)
localDomain := dao.DBClient.GetLocalRegion().Domain
normalStatus := types.SubscriptionStatusNormal
canceledStatus := types.SubscriptionPayStatusCanceled
balancePayMethod := types.PaymentMethodBalance
// 查询已到期且支付方式为余额的订阅
err := dao.DBClient.GetGlobalDB().WithContext(ctx).Model(&types.WorkspaceSubscription{}).
Where("current_period_end_at <= ? AND pay_method = ? AND status = ? AND region_domain = ? AND pay_status NOT IN (?, ?)",
now.Add(20*time.Minute),
types.PaymentMethodBalance,
types.SubscriptionStatusNormal,
dao.DBClient.GetLocalRegion().Domain,
types.SubscriptionPayStatusCanceled,
types.SubscriptionPayStatusNoNeed).
Find(&expiredSubscriptions).Error
err := dao.DBClient.GetGlobalDB().WithContext(ctx).
Model(&types.WorkspaceSubscription{}).
Where("current_period_end_at <= ? AND status = ? AND region_domain = ? AND pay_status != ? AND (pay_method = ? OR (expire_at > current_period_end_at))",
renewalThreshold,
normalStatus,
localDomain,
canceledStatus,
balancePayMethod).
Find(&subscriptionsToProcess).Error
if err != nil {
return 0, fmt.Errorf("failed to query expired balance subscriptions: %w", err)
return 0, fmt.Errorf("failed to query subscriptions needing period renewal: %w", err)
}
if len(expiredSubscriptions) == 0 {
if len(subscriptionsToProcess) == 0 {
return 0, nil
}
logrus.Infof("Found %d expired balance subscriptions to process", len(expiredSubscriptions))
logrus.Infof(
"Found %d subscriptions needing period renewal (includes expired and expiring subscriptions with remaining prepaid time)",
len(subscriptionsToProcess),
)
processedCount := 0
for i := range expiredSubscriptions {
if err := wsp.processExpiredBalanceSubscription(ctx, &expiredSubscriptions[i]); err != nil {
for i := range subscriptionsToProcess {
if err := wsp.processPeriodRenewal(ctx, &subscriptionsToProcess[i]); err != nil {
dao.Logger.Errorf(
"Failed to process expired balance subscription %s: %v",
expiredSubscriptions[i].ID,
"Failed to process period renewal for subscription %s: %v",
subscriptionsToProcess[i].ID,
err,
)
} else {
@@ -651,39 +660,38 @@ func (wsp *WorkspaceSubscriptionProcessor) processExpiredBalanceSubscriptions(
return processedCount, nil
}
// processExpiredBalanceSubscription 处理单个到期的余额支付订阅
func (wsp *WorkspaceSubscriptionProcessor) processExpiredBalanceSubscription(
// processPeriodRenewal 处理订阅的周期续期
func (wsp *WorkspaceSubscriptionProcessor) processPeriodRenewal(
ctx context.Context,
sub *types.WorkspaceSubscription,
) error {
return dao.DBClient.GetGlobalDB().Transaction(func(dbTx *gorm.DB) error {
// 重新获取最新的订阅状态,防止并发问题
// Re-obtain the latest subscription status to prevent concurrent issues
var latestSub types.WorkspaceSubscription
if err := dbTx.Where("id = ?", sub.ID).First(&latestSub).Error; err != nil {
return fmt.Errorf("failed to get latest subscription: %w", err)
}
// 再次检查是否需要处理
// check again to see if any processing is needed
now := time.Now().UTC()
if latestSub.CurrentPeriodEndAt.After(now) ||
latestSub.PayMethod != types.PaymentMethodBalance ||
latestSub.Status != types.SubscriptionStatusNormal {
logrus.Infof("Subscription %s no longer needs processing, skipping", latestSub.ID)
// 检查订阅状态
if latestSub.Status != types.SubscriptionStatusNormal {
logrus.Infof("Subscription %s status is not normal, skipping", latestSub.ID)
return nil
}
logrus.Infof(
"Processing expired balance subscription: workspace=%s, region=%s, plan=%s, expired_at=%s",
latestSub.Workspace,
latestSub.RegionDomain,
latestSub.PlanName,
latestSub.CurrentPeriodEndAt.Format(time.RFC3339),
)
account, err := dao.DBClient.GetAccount(types.UserQueryOpts{UID: latestSub.UserUID})
if err != nil {
return fmt.Errorf("failed to get account for user %s: %w", latestSub.UserUID, err)
// Check if the current cycle has not yet reached the renewal time (leave a 20-minute buffer)
if latestSub.CurrentPeriodEndAt.After(now.Add(20 * time.Minute)) {
logrus.Infof("Subscription %s current period not yet ending (ends at %s), skipping",
latestSub.ID, latestSub.CurrentPeriodEndAt.Format(time.RFC3339))
return nil
}
// determine whether the subscription has expired
isExpired := latestSub.CurrentPeriodEndAt.Before(now)
// obtain plan information
plan, err := dao.DBClient.GetWorkspaceSubscriptionPlan(latestSub.PlanName)
if err != nil {
return fmt.Errorf(
@@ -693,29 +701,158 @@ func (wsp *WorkspaceSubscriptionProcessor) processExpiredBalanceSubscription(
)
}
price, err := dao.DBClient.GetWorkspaceSubscriptionPlanPrice(
latestSub.PlanName,
types.SubscriptionPeriodMonthly,
)
if err != nil {
return fmt.Errorf("failed to get plan price for %s: %w", latestSub.PlanName, err)
// Calculate the new cycle time
// For expired subscriptions, the new cycle begins now; For subscriptions that are about to expire, start from the end time of the current cycle
var newPeriodStart time.Time
if isExpired {
newPeriodStart = now
} else {
newPeriodStart = latestSub.CurrentPeriodEndAt
}
// 检查余额是否足够
availableBalance := account.Balance - account.DeductionBalance
if availableBalance < price.Price {
// 余额不足,更新订阅状态为欠费
return wsp.handleInsufficientBalance(
ctx,
dbTx,
&latestSub,
price.Price,
availableBalance,
monthlyPeriod, _ := types.ParsePeriod(types.SubscriptionPeriodMonthly)
potentialPeriodEnd := newPeriodStart.Add(monthlyPeriod)
newPeriodEnd := potentialPeriodEnd
needsPayment := false
var paymentID string
var paymentAmount int64 = 0
// 检查是否还有剩余订阅时长
if potentialPeriodEnd.After(*latestSub.ExpireAt) {
if latestSub.CurrentPeriodEndAt.Before(*latestSub.ExpireAt) {
newPeriodEnd = *latestSub.ExpireAt
} else {
needsPayment = true
}
}
if needsPayment {
// Only subscriptions with balance payments are processed here. Other payment methods such as stripe are not handled
if latestSub.PayMethod != types.PaymentMethodBalance {
return nil
}
price, err := dao.DBClient.GetWorkspaceSubscriptionPlanPrice(
latestSub.PlanName,
types.SubscriptionPeriodMonthly,
)
if err != nil {
return fmt.Errorf("failed to get plan price for %s: %w", latestSub.PlanName, err)
}
account, err := dao.DBClient.GetAccount(types.UserQueryOpts{UID: latestSub.UserUID})
if err != nil {
return fmt.Errorf("failed to get account for user %s: %w", latestSub.UserUID, err)
}
availableBalance := account.Balance - account.DeductionBalance
if availableBalance < price.Price {
return wsp.handleInsufficientBalance(
ctx,
dbTx,
&latestSub,
price.Price,
availableBalance,
)
}
_payID, err := gonanoid.New(12)
if err != nil {
return fmt.Errorf("failed to create payment id: %w", err)
}
paymentID = _payID
paymentAmount = price.Price
if err := cockroach.AddDeductionAccount(dbTx, latestSub.UserUID, price.Price); err != nil {
return fmt.Errorf("failed to deduct balance: %w", err)
}
payment := types.Payment{
ID: paymentID,
PaymentRaw: types.PaymentRaw{
UserUID: latestSub.UserUID,
RegionUID: dao.DBClient.GetLocalRegion().UID,
CreatedAt: now,
Method: types.PaymentMethodBalance,
Amount: price.Price,
TradeNO: paymentID,
Type: types.PaymentTypeSubscription,
ChargeSource: types.ChargeSourceBalance,
Status: types.PaymentStatusPAID,
WorkspaceSubscriptionID: &latestSub.ID,
Message: fmt.Sprintf(
"Period renewal payment for workspace %s/%s",
latestSub.Workspace,
latestSub.RegionDomain,
),
},
}
if err := dbTx.Create(&payment).Error; err != nil {
return fmt.Errorf("failed to create payment record: %w", err)
}
}
// 余额充足,执行续费
return wsp.handleSuccessfulBalanceRenewal(ctx, dbTx, &latestSub, plan, price)
latestSub.CurrentPeriodStartAt = newPeriodStart
latestSub.CurrentPeriodEndAt = newPeriodEnd
latestSub.Status = types.SubscriptionStatusNormal
latestSub.TrafficStatus = types.WorkspaceTrafficStatusActive
latestSub.UpdateAt = now
if err := dbTx.Save(&latestSub).Error; err != nil {
return fmt.Errorf("failed to update subscription: %w", err)
}
var payStatus types.SubscriptionPayStatus
var statusDesc string
if needsPayment {
payStatus = types.SubscriptionPayStatusPaid
statusDesc = "Auto period renewal with payment by processor"
} else {
payStatus = types.SubscriptionPayStatusNoNeed
statusDesc = "Auto period renewal using prepaid time (no payment needed)"
}
transaction := types.WorkspaceSubscriptionTransaction{
ID: uuid.New(),
From: types.TransactionFromSystem,
Workspace: latestSub.Workspace,
RegionDomain: latestSub.RegionDomain,
UserUID: latestSub.UserUID,
OldPlanName: latestSub.PlanName,
NewPlanName: latestSub.PlanName,
OldPlanStatus: types.SubscriptionStatusNormal,
Operator: types.SubscriptionTransactionTypeRenewed,
StartAt: now,
CreatedAt: now,
UpdatedAt: now,
Status: types.SubscriptionTransactionStatusCompleted,
StatusDesc: statusDesc,
PayStatus: payStatus,
PayID: paymentID,
Period: types.SubscriptionPeriodMonthly,
Amount: paymentAmount,
}
if err := dbTx.Create(&transaction).Error; err != nil {
return fmt.Errorf("failed to create renewal transaction: %w", err)
}
if err = helper.AddTrafficPackage(dbTx, dao.K8sManager.GetClient(), &latestSub, plan, newPeriodEnd, types.WorkspaceTrafficFromWorkspaceSubscription, transaction.ID.String()); err != nil {
return fmt.Errorf("failed to add traffic package: %w", err)
}
if err = cockroach.AddWorkspaceSubscriptionAIQuotaPackage(dbTx, latestSub.ID, plan.AIQuota, newPeriodEnd, types.PKGFromWorkspaceSubscription, transaction.ID.String()); err != nil {
return fmt.Errorf("failed to create AI quota package: %w", err)
}
logrus.Infof(
"Successfully processed period renewal for workspace %s, new period: %s to %s, expire_at: %s",
latestSub.Workspace,
latestSub.CurrentPeriodStartAt.Format(time.RFC3339),
latestSub.CurrentPeriodEndAt.Format(time.RFC3339),
latestSub.ExpireAt.Format(time.RFC3339),
)
return nil
})
}
@@ -827,116 +964,3 @@ func (wsp *WorkspaceSubscriptionProcessor) handleInsufficientBalance(
)
return nil
}
// handleSuccessfulBalanceRenewal 处理成功的余额续费
func (wsp *WorkspaceSubscriptionProcessor) handleSuccessfulBalanceRenewal(
_ context.Context,
dbTx *gorm.DB,
sub *types.WorkspaceSubscription,
plan *types.WorkspaceSubscriptionPlan,
price *types.ProductPrice,
) error {
now := time.Now().UTC()
paymentID, err := gonanoid.New(12)
if err != nil {
return fmt.Errorf("failed to create payment id: %w", err)
}
logrus.Infof(
"Processing successful balance renewal for workspace %s, amount: %d",
sub.Workspace,
price.Price,
)
periodDuration, err := types.ParsePeriod(types.SubscriptionPeriodMonthly)
if err != nil {
return fmt.Errorf("failed to parse period: %w", err)
}
// 设置新的周期时间
if sub.CurrentPeriodEndAt.Before(now) {
sub.CurrentPeriodStartAt = now
sub.CurrentPeriodEndAt = now.Add(periodDuration)
} else {
sub.CurrentPeriodStartAt = sub.CurrentPeriodEndAt
sub.CurrentPeriodEndAt = sub.CurrentPeriodEndAt.Add(periodDuration)
}
sub.Status = types.SubscriptionStatusNormal
sub.PayStatus = types.SubscriptionPayStatusPaid
sub.TrafficStatus = types.WorkspaceTrafficStatusActive
sub.UpdateAt = now
if err := dbTx.Save(sub).Error; err != nil {
return fmt.Errorf("failed to update subscription: %w", err)
}
// 创建成功的续费事务记录
successTransaction := types.WorkspaceSubscriptionTransaction{
ID: uuid.New(),
From: types.TransactionFromSystem,
Workspace: sub.Workspace,
RegionDomain: sub.RegionDomain,
UserUID: sub.UserUID,
OldPlanName: sub.PlanName,
NewPlanName: sub.PlanName,
OldPlanStatus: types.SubscriptionStatusNormal,
Operator: types.SubscriptionTransactionTypeRenewed,
StartAt: now,
CreatedAt: now,
UpdatedAt: now,
Status: types.SubscriptionTransactionStatusCompleted,
StatusDesc: "Auto renewal successful",
PayStatus: types.SubscriptionPayStatusPaid,
PayID: paymentID,
Period: types.SubscriptionPeriodMonthly,
Amount: price.Price,
}
if err := dbTx.Create(&successTransaction).Error; err != nil {
return fmt.Errorf("failed to create successful renewal transaction: %w", err)
}
if err := cockroach.AddDeductionAccount(dbTx, sub.UserUID, price.Price); err != nil {
return fmt.Errorf("failed to deduct balance: %w", err)
}
payment := types.Payment{
ID: paymentID,
PaymentRaw: types.PaymentRaw{
UserUID: sub.UserUID,
RegionUID: dao.DBClient.GetLocalRegion().UID,
CreatedAt: now,
Method: types.PaymentMethodBalance,
Amount: price.Price,
TradeNO: successTransaction.ID.String(),
Type: types.PaymentTypeSubscription,
ChargeSource: types.ChargeSourceBalance,
Status: types.PaymentStatusPAID,
WorkspaceSubscriptionID: &sub.ID,
Message: fmt.Sprintf(
"Auto renewal for workspace %s/%s",
sub.Workspace,
sub.RegionDomain,
),
},
}
if err := dbTx.Create(&payment).Error; err != nil {
return fmt.Errorf("failed to create payment record: %w", err)
}
if err = helper.AddTrafficPackage(dbTx, dao.K8sManager.GetClient(), sub, plan, sub.CurrentPeriodEndAt, types.WorkspaceTrafficFromWorkspaceSubscription, successTransaction.ID.String()); err != nil {
return fmt.Errorf("failed to add traffic package: %w", err)
}
if err = cockroach.AddWorkspaceSubscriptionAIQuotaPackage(dbTx, sub.ID, plan.AIQuota, sub.CurrentPeriodEndAt, types.PKGFromWorkspaceSubscription, successTransaction.ID.String()); err != nil {
return fmt.Errorf("failed to create AI quota package: %w", err)
}
logrus.Infof(
"Successfully processed balance renewal for workspace %s, new period: %s to %s",
sub.Workspace,
sub.CurrentPeriodStartAt.Format(time.RFC3339),
sub.CurrentPeriodEndAt.Format(time.RFC3339),
)
return nil
}
+1 -1
View File
@@ -1185,7 +1185,7 @@ func (m *MongoDB) GetAppCosts(req *helper.AppCostsReq) (results *common.AppCosts
}
if strings.ToUpper(req.AppType) != resources.AppStore {
var match = make(bson.D, len(matchConditions))
match := make(bson.D, len(matchConditions))
copy(match, matchConditions[:])
if req.AppType != "" {
match = append(
+3 -1
View File
@@ -13,6 +13,8 @@ import (
// @host localhost:2333
// @BasePath
func main() {
go pprof.RunPprofServer(10000)
go func() {
_ = pprof.RunPprofServer(10000)
}()
router.RegisterPayRouter()
}
-1
View File
@@ -3,7 +3,6 @@ package pprof
import (
"net"
"net/http"
//nolint:gosec
_ "net/http/pprof"
"strconv"