fix(order): prevent duplicate subscriptions and repair invite gifts
Build docker and publish / build (20.15.1) (push) Successful in 5m6s

This commit is contained in:
2026-04-24 21:16:21 -07:00
parent ae62ecc6b3
commit 9db4762904
17 changed files with 3013 additions and 52 deletions
+106 -28
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/perfect-panel/server/internal/logic/admin/group"
commonLogic "github.com/perfect-panel/server/internal/logic/common"
"github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/logger"
@@ -44,6 +45,7 @@ const (
OrderStatusPaid = 2 // Order paid and ready for processing
OrderStatusClose = 3 // Order closed/cancelled
OrderStatusFailed = 4 // Order processing failed
OrderStatusClaimed = 4 // Internal transient claim while a worker processes the order
OrderStatusFinished = 5 // Order successfully completed
)
@@ -82,7 +84,7 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task)
logger.WithContext(ctx).Info("[ActivateOrderLogic] 正在验证订单",
logger.Field("order_no", payload.OrderNo))
orderInfo, err := l.validateAndGetOrder(ctx, payload.OrderNo)
orderInfo, err := l.claimAndGetOrder(ctx, payload.OrderNo)
if err != nil {
// 如果订单不存在或状态不对,不重试
if errors.Is(err, ErrInvalidOrderStatus) {
@@ -108,6 +110,7 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task)
logger.Field("user_id", orderInfo.UserId))
if err = l.processOrderByType(ctx, orderInfo, payload.IAPExpireAt); err != nil {
l.releaseClaim(ctx, orderInfo.OrderNo)
logger.WithContext(ctx).Error("[ActivateOrderLogic] 处理订单失败,将重试",
logger.Field("order_no", orderInfo.OrderNo),
logger.Field("order_type", orderInfo.Type),
@@ -137,10 +140,11 @@ func (l *ActivateOrderLogic) parsePayload(ctx context.Context, payload []byte) (
return &p, nil
}
// validateAndGetOrder retrieves an order by order number and validates its status
// claimAndGetOrder retrieves an order by order number and atomically claims paid orders.
// Returns error if order is not found or not in paid status
func (l *ActivateOrderLogic) validateAndGetOrder(ctx context.Context, orderNo string) (*order.Order, error) {
orderInfo, err := l.svc.OrderModel.FindOneByOrderNo(ctx, orderNo)
func (l *ActivateOrderLogic) claimAndGetOrder(ctx context.Context, orderNo string) (*order.Order, error) {
var orderInfo order.Order
err := l.svc.DB.WithContext(ctx).Model(&order.Order{}).Where("order_no = ?", orderNo).First(&orderInfo).Error
if err != nil {
logger.WithContext(ctx).Error("Find order failed",
logger.Field("error", err.Error()),
@@ -165,7 +169,33 @@ func (l *ActivateOrderLogic) validateAndGetOrder(ctx context.Context, orderNo st
return nil, ErrInvalidOrderStatus
}
return orderInfo, nil
result := l.svc.DB.WithContext(ctx).
Model(&order.Order{}).
Where("order_no = ? AND status = ?", orderNo, OrderStatusPaid).
Update("status", OrderStatusClaimed)
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 0 {
logger.WithContext(ctx).Info("Order already claimed by another worker, skip processing",
logger.Field("order_no", orderNo),
)
return nil, nil
}
orderInfo.Status = OrderStatusClaimed
return &orderInfo, nil
}
func (l *ActivateOrderLogic) releaseClaim(ctx context.Context, orderNo string) {
if err := l.svc.DB.WithContext(ctx).
Model(&order.Order{}).
Where("order_no = ? AND status = ?", orderNo, OrderStatusClaimed).
Update("status", OrderStatusPaid).Error; err != nil {
logger.WithContext(ctx).Error("Release order claim failed",
logger.Field("error", err.Error()),
logger.Field("order_no", orderNo),
)
}
}
// processOrderByType routes order processing based on the order type
@@ -274,20 +304,24 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O
)
}
// 如果没有合并已购订阅,再尝试合并赠送订阅(order_id=0)
if userSub == nil {
giftSub, giftErr := l.findGiftSubscription(ctx, singleModeUserId, orderInfo.SubscribeId)
if giftErr == nil && giftSub != nil {
// 在赠送订阅上延长时间,保持 token 不变
userSub, err = l.extendGiftSubscription(ctx, giftSub, orderInfo, sub)
if err != nil {
logger.WithContext(ctx).Error("Extend gift subscription failed",
logger.Field("error", err.Error()),
logger.Field("gift_subscribe_id", giftSub.Id),
)
// 合并失败时回退到创建新订阅
userSub = nil
}
}
// 如果没有合并已购订阅,再尝试合并赠送订阅(order_id=0)。
// 全局单订阅口径下,非 SingleModel 也不能让试用订阅和付费订阅并存。
if userSub == nil {
effectiveOwner := orderInfo.UserId
if orderInfo.SubscriptionUserId > 0 {
effectiveOwner = orderInfo.SubscriptionUserId
}
giftSub, giftErr := l.findGiftSubscription(ctx, effectiveOwner, orderInfo.SubscribeId)
if giftErr == nil && giftSub != nil {
userSub, err = l.extendGiftSubscription(ctx, giftSub, orderInfo, sub)
if err != nil {
logger.WithContext(ctx).Error("Extend gift subscription failed",
logger.Field("error", err.Error()),
logger.Field("gift_subscribe_id", giftSub.Id),
)
userSub = nil
}
}
}
@@ -302,8 +336,10 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O
}
var existingSub user.Subscribe
if findErr := l.svc.DB.Model(&user.Subscribe{}).
Where("user_id IN ? AND subscribe_id = ?", candidateUserIds, orderInfo.SubscribeId).
Where("user_id IN ? AND token != ''", candidateUserIds).
Order("expire_time DESC").
Order("updated_at DESC").
Order("id DESC").
First(&existingSub).Error; findErr == nil {
// 家庭组场景:订阅 owner 可能变更(如成员注册的试用 → 被家主收归),
// 续期前把 user_id 校正为当前订单的 SubscriptionUserId
@@ -514,7 +550,7 @@ func (l *ActivateOrderLogic) createUserSubscription(ctx context.Context, orderIn
// Check quota limit before creating subscription (final safeguard)
if sub.Quota > 0 {
var count int64
if err := l.svc.DB.Model(&user.Subscribe{}).Where("user_id = ? AND subscribe_id = ?", orderInfo.UserId, orderInfo.SubscribeId).Count(&count).Error; err != nil {
if err := l.svc.DB.Model(&user.Subscribe{}).Where("user_id = ?", subscriptionUserId).Count(&count).Error; err != nil {
logger.WithContext(ctx).Error("Count user subscribe failed", logger.Field("error", err.Error()))
return nil, err
}
@@ -602,7 +638,7 @@ func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *use
if !l.shouldProcessCommission(userInfo, orderInfo.IsNew) {
// 普通用户路径(佣金比例=0):只有首单才双方赠N天
if orderInfo.IsNew {
l.grantGiftDaysToBothParties(ctx, userInfo, orderInfo.OrderNo)
l.grantGiftDaysToBothParties(ctx, userInfo, orderInfo)
}
return
}
@@ -692,16 +728,18 @@ func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *use
// 有佣金路径:邀请人拿佣金,被邀请用户(首单)拿天数
if orderInfo.IsNew {
_ = l.grantGiftDays(ctx, userInfo, int(l.svc.Config.Invite.GiftDays), orderInfo.OrderNo, "邀请赠送")
giftTarget := l.resolveGiftTargetUser(ctx, userInfo, orderInfo.SubscriptionUserId)
_ = l.grantGiftDays(ctx, giftTarget, int(l.svc.Config.Invite.GiftDays), orderInfo.OrderNo, "邀请赠送")
}
}
func (l *ActivateOrderLogic) grantGiftDaysToBothParties(ctx context.Context, referee *user.User, orderNo string) {
func (l *ActivateOrderLogic) grantGiftDaysToBothParties(ctx context.Context, referee *user.User, orderInfo *order.Order) {
giftDays := l.svc.Config.Invite.GiftDays
if giftDays <= 0 || referee == nil || referee.Id == 0 || referee.RefererId == 0 {
if giftDays <= 0 || referee == nil || referee.Id == 0 || referee.RefererId == 0 || orderInfo == nil {
return
}
_ = l.grantGiftDays(ctx, referee, int(giftDays), orderNo, "邀请赠送")
refereeTarget := l.resolveGiftTargetUser(ctx, referee, orderInfo.SubscriptionUserId)
_ = l.grantGiftDays(ctx, refereeTarget, int(giftDays), orderInfo.OrderNo, "邀请赠送")
if referee.RefererId == 0 {
return
}
@@ -709,7 +747,32 @@ func (l *ActivateOrderLogic) grantGiftDaysToBothParties(ctx context.Context, ref
if err != nil || referer == nil {
return
}
_ = l.grantGiftDays(ctx, referer, int(giftDays), orderNo, "邀请赠送")
refererTarget := l.resolveGiftTargetUser(ctx, referer, 0)
_ = l.grantGiftDays(ctx, refererTarget, int(giftDays), orderInfo.OrderNo, "邀请赠送")
}
func (l *ActivateOrderLogic) resolveGiftTargetUser(ctx context.Context, source *user.User, forcedOwnerID int64) *user.User {
if source == nil || source.Id == 0 {
return source
}
targetID := source.Id
if forcedOwnerID > 0 {
targetID = forcedOwnerID
} else if entitlement, err := commonLogic.ResolveEntitlementUser(ctx, l.svc.DB, source.Id); err == nil && entitlement != nil && entitlement.EffectiveUserID > 0 {
targetID = entitlement.EffectiveUserID
}
if targetID == source.Id {
return source
}
target, err := l.svc.UserModel.FindOne(ctx, targetID)
if err != nil || target == nil {
logger.WithContext(ctx).Error("Resolve gift target owner failed",
logger.Field("source_user_id", source.Id),
logger.Field("target_user_id", targetID),
)
return source
}
return target
}
func (l *ActivateOrderLogic) grantGiftDays(ctx context.Context, u *user.User, days int, orderNo string, remark string) error {
@@ -736,7 +799,22 @@ func (l *ActivateOrderLogic) grantGiftDays(ctx context.Context, u *user.User, da
activeSubscribe, err := l.svc.UserModel.FindActiveSubscribe(ctx, u.Id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
giftLog := &log.Gift{
Type: log.GiftTypeIncrease,
OrderNo: orderNo,
SubscribeId: 0,
Amount: int64(days),
Balance: u.Balance,
Remark: remark + " skipped: no active subscription",
Timestamp: time.Now().UnixMilli(),
}
content, _ := giftLog.Marshal()
return l.svc.LogModel.Insert(ctx, &log.SystemLog{
Type: log.TypeGift.Uint8(),
Date: time.Now().Format("2006-01-02"),
ObjectID: u.Id,
Content: string(content),
})
}
return err
}