fix(order): prevent duplicate subscriptions and repair invite gifts
Build docker and publish / build (20.15.1) (push) Successful in 5m6s
Build docker and publish / build (20.15.1) (push) Successful in 5m6s
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
package orderLogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
userLogic "github.com/perfect-panel/server/internal/logic/public/user"
|
||||
modelLog "github.com/perfect-panel/server/internal/model/log"
|
||||
"github.com/perfect-panel/server/internal/model/order"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/constant"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 普通用户 + 首单 → 双方赠N天
|
||||
func TestHandleCommission_GrantGiftDaysWhenCommissionDisabled_FirstOrder(t *testing.T) {
|
||||
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
|
||||
ReferralPercentage: 0,
|
||||
OnlyFirstPurchase: false,
|
||||
GiftDays: 2,
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
referee := seedUser(t, db, 0, false)
|
||||
referer := seedUser(t, db, 0, false)
|
||||
referee.RefererId = referer.Id
|
||||
|
||||
baseExpire := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
refereeSub := seedActiveSubscribe(t, db, referee.Id, baseExpire)
|
||||
refererSub := seedActiveSubscribe(t, db, referer.Id, baseExpire)
|
||||
|
||||
logic.handleCommission(context.Background(), referee, &order.Order{
|
||||
OrderNo: "ORD-GIFT-001",
|
||||
Type: OrderTypeSubscribe,
|
||||
IsNew: true, // 首单
|
||||
Amount: 100,
|
||||
FeeAmount: 0,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
|
||||
assertExpireIncreasedByDays(t, db, refereeSub.Id, baseExpire, 2)
|
||||
assertExpireIncreasedByDays(t, db, refererSub.Id, baseExpire, 2)
|
||||
|
||||
var giftCount int64
|
||||
if err := db.Model(&modelLog.SystemLog{}).Where("type = ?", modelLog.TypeGift.Uint8()).Count(&giftCount).Error; err != nil {
|
||||
t.Fatalf("count gift logs failed: %v", err)
|
||||
}
|
||||
if giftCount != 2 {
|
||||
t.Fatalf("expected 2 gift logs, got %d", giftCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 普通用户 + 非首单 → 不赠送
|
||||
func TestHandleCommission_NoGiftDaysWhenCommissionDisabled_NotFirstOrder(t *testing.T) {
|
||||
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
|
||||
ReferralPercentage: 0,
|
||||
OnlyFirstPurchase: false,
|
||||
GiftDays: 2,
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
referee := seedUser(t, db, 0, false)
|
||||
referer := seedUser(t, db, 0, false)
|
||||
referee.RefererId = referer.Id
|
||||
|
||||
baseExpire := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
refereeSub := seedActiveSubscribe(t, db, referee.Id, baseExpire)
|
||||
refererSub := seedActiveSubscribe(t, db, referer.Id, baseExpire)
|
||||
|
||||
logic.handleCommission(context.Background(), referee, &order.Order{
|
||||
OrderNo: "ORD-GIFT-002",
|
||||
Type: OrderTypeSubscribe,
|
||||
IsNew: false, // 非首单
|
||||
Amount: 100,
|
||||
FeeAmount: 0,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
|
||||
// 到期时间不应延长
|
||||
assertExpireIncreasedByDays(t, db, refereeSub.Id, baseExpire, 0)
|
||||
assertExpireIncreasedByDays(t, db, refererSub.Id, baseExpire, 0)
|
||||
|
||||
var giftCount int64
|
||||
if err := db.Model(&modelLog.SystemLog{}).Where("type = ?", modelLog.TypeGift.Uint8()).Count(&giftCount).Error; err != nil {
|
||||
t.Fatalf("count gift logs failed: %v", err)
|
||||
}
|
||||
if giftCount != 0 {
|
||||
t.Fatalf("expected 0 gift logs for non-first order, got %d", giftCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 渠道 + 首单 → 被邀请人赠N天 + 邀请人获佣金
|
||||
func TestHandleCommission_GiftDaysAndCommissionWhenChannelFirstOrder(t *testing.T) {
|
||||
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
|
||||
ReferralPercentage: 10,
|
||||
OnlyFirstPurchase: false,
|
||||
GiftDays: 2,
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
referee := seedUser(t, db, 0, false)
|
||||
referer := seedUser(t, db, 0, false)
|
||||
referee.RefererId = referer.Id
|
||||
|
||||
baseExpire := time.Now().Add(96 * time.Hour).Truncate(time.Second)
|
||||
refereeSub := seedActiveSubscribe(t, db, referee.Id, baseExpire)
|
||||
|
||||
logic.handleCommission(context.Background(), referee, &order.Order{
|
||||
OrderNo: "ORD-COMM-001",
|
||||
Type: OrderTypeSubscribe,
|
||||
IsNew: true, // 首单
|
||||
Amount: 100,
|
||||
FeeAmount: 0,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
|
||||
// 被邀请人(首单)应获得赠送天数
|
||||
assertExpireIncreasedByDays(t, db, refereeSub.Id, baseExpire, 2)
|
||||
|
||||
// 邀请人应获得佣金
|
||||
var refererAfter user.User
|
||||
if err := db.First(&refererAfter, referer.Id).Error; err != nil {
|
||||
t.Fatalf("query referer failed: %v", err)
|
||||
}
|
||||
if refererAfter.Commission != 10 {
|
||||
t.Fatalf("expected referer commission=10, got %d", refererAfter.Commission)
|
||||
}
|
||||
|
||||
var giftCount int64
|
||||
if err := db.Model(&modelLog.SystemLog{}).Where("type = ?", modelLog.TypeGift.Uint8()).Count(&giftCount).Error; err != nil {
|
||||
t.Fatalf("count gift logs failed: %v", err)
|
||||
}
|
||||
if giftCount != 1 {
|
||||
t.Fatalf("expected 1 gift log for referee on first order with commission, got %d", giftCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 渠道 + 非首单 → 只给邀请人佣金,不赠天
|
||||
func TestHandleCommission_OnlyCommissionWhenChannelNotFirstOrder(t *testing.T) {
|
||||
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
|
||||
ReferralPercentage: 10,
|
||||
OnlyFirstPurchase: false,
|
||||
GiftDays: 2,
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
referee := seedUser(t, db, 0, false)
|
||||
referer := seedUser(t, db, 0, false)
|
||||
referee.RefererId = referer.Id
|
||||
|
||||
baseExpire := time.Now().Add(96 * time.Hour).Truncate(time.Second)
|
||||
refereeSub := seedActiveSubscribe(t, db, referee.Id, baseExpire)
|
||||
|
||||
logic.handleCommission(context.Background(), referee, &order.Order{
|
||||
OrderNo: "ORD-COMM-002",
|
||||
Type: OrderTypeSubscribe,
|
||||
IsNew: false, // 非首单
|
||||
Amount: 100,
|
||||
FeeAmount: 0,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
|
||||
// 被邀请人不应获得赠送天数
|
||||
assertExpireIncreasedByDays(t, db, refereeSub.Id, baseExpire, 0)
|
||||
|
||||
// 邀请人应获得佣金
|
||||
var refererAfter user.User
|
||||
if err := db.First(&refererAfter, referer.Id).Error; err != nil {
|
||||
t.Fatalf("query referer failed: %v", err)
|
||||
}
|
||||
if refererAfter.Commission != 10 {
|
||||
t.Fatalf("expected referer commission=10, got %d", refererAfter.Commission)
|
||||
}
|
||||
|
||||
var giftCount int64
|
||||
if err := db.Model(&modelLog.SystemLog{}).Where("type = ?", modelLog.TypeGift.Uint8()).Count(&giftCount).Error; err != nil {
|
||||
t.Fatalf("count gift logs failed: %v", err)
|
||||
}
|
||||
if giftCount != 0 {
|
||||
t.Fatalf("expected 0 gift logs when channel non-first order, got %d", giftCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCommission_NoGiftDaysWhenNoInviteRelation(t *testing.T) {
|
||||
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
|
||||
ReferralPercentage: 0,
|
||||
OnlyFirstPurchase: false,
|
||||
GiftDays: 2,
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
// 没有邀请人的独立用户
|
||||
loneUser := seedUser(t, db, 0, false)
|
||||
// RefererId == 0,无邀请关系
|
||||
|
||||
baseExpire := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
loneSub := seedActiveSubscribe(t, db, loneUser.Id, baseExpire)
|
||||
|
||||
logic.handleCommission(context.Background(), loneUser, &order.Order{
|
||||
OrderNo: "ORD-LONE-001",
|
||||
Type: OrderTypeSubscribe,
|
||||
IsNew: true,
|
||||
Amount: 100,
|
||||
FeeAmount: 0,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
|
||||
// 订阅到期时间不应该被延长
|
||||
var subAfter user.Subscribe
|
||||
if err := db.First(&subAfter, loneSub.Id).Error; err != nil {
|
||||
t.Fatalf("query subscribe failed: %v", err)
|
||||
}
|
||||
if !subAfter.ExpireTime.Equal(baseExpire) {
|
||||
t.Fatalf("expected no gift days for user without inviter, before=%v after=%v", baseExpire, subAfter.ExpireTime)
|
||||
}
|
||||
|
||||
// 不应产生赠天日志
|
||||
var giftCount int64
|
||||
if err := db.Model(&modelLog.SystemLog{}).Where("type = ?", modelLog.TypeGift.Uint8()).Count(&giftCount).Error; err != nil {
|
||||
t.Fatalf("count gift logs failed: %v", err)
|
||||
}
|
||||
if giftCount != 0 {
|
||||
t.Fatalf("expected 0 gift logs for user without inviter, got %d", giftCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 先绑码后首单 → 双方赠N天
|
||||
func TestInviteFlow_BindThenFirstOrder_GrantGiftDays(t *testing.T) {
|
||||
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
|
||||
ReferralPercentage: 0,
|
||||
OnlyFirstPurchase: false,
|
||||
GiftDays: 2,
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
referee := seedUser(t, db, 0, false)
|
||||
referer := seedUser(t, db, 0, false)
|
||||
referer.ReferCode = fmt.Sprintf("REF-%d", referer.Id)
|
||||
if err := db.Model(&user.User{}).Where("id = ?", referer.Id).Update("refer_code", referer.ReferCode).Error; err != nil {
|
||||
t.Fatalf("update referer code failed: %v", err)
|
||||
}
|
||||
|
||||
refereeBaseExpire := time.Now().Add(48 * time.Hour).Truncate(time.Second)
|
||||
refererBaseExpire := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
refereeSub := seedActiveSubscribe(t, db, referee.Id, refereeBaseExpire)
|
||||
refererSub := seedActiveSubscribe(t, db, referer.Id, refererBaseExpire)
|
||||
|
||||
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, referee)
|
||||
bindLogic := userLogic.NewBindInviteCodeLogic(ctx, logic.svc)
|
||||
if err := bindLogic.BindInviteCode(&types.BindInviteCodeRequest{InviteCode: referer.ReferCode}); err != nil {
|
||||
t.Fatalf("bind invite code failed: %v", err)
|
||||
}
|
||||
|
||||
var refereeAfterBind user.User
|
||||
if err := db.First(&refereeAfterBind, referee.Id).Error; err != nil {
|
||||
t.Fatalf("query referee after bind failed: %v", err)
|
||||
}
|
||||
if refereeAfterBind.RefererId != referer.Id {
|
||||
t.Fatalf("bind invite failed, expected referer_id=%d got=%d", referer.Id, refereeAfterBind.RefererId)
|
||||
}
|
||||
|
||||
// 首单 IsNew=true → 双方赠N天
|
||||
logic.handleCommission(context.Background(), &refereeAfterBind, &order.Order{
|
||||
OrderNo: "ORD-FLOW-001",
|
||||
Type: OrderTypeSubscribe,
|
||||
IsNew: true,
|
||||
Amount: 100,
|
||||
FeeAmount: 0,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
|
||||
assertExpireIncreasedByDays(t, db, refereeSub.Id, refereeBaseExpire, 2)
|
||||
assertExpireIncreasedByDays(t, db, refererSub.Id, refererBaseExpire, 2)
|
||||
}
|
||||
|
||||
// 先买订单后绑码再续费 → 不赠送(IsNew=false)
|
||||
func TestInviteFlow_OrderThenBind_NoGiftDays(t *testing.T) {
|
||||
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
|
||||
ReferralPercentage: 0,
|
||||
OnlyFirstPurchase: false,
|
||||
GiftDays: 2,
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
referee := seedUser(t, db, 0, false)
|
||||
referer := seedUser(t, db, 0, false)
|
||||
referee.RefererId = referer.Id
|
||||
|
||||
baseExpire := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||
refereeSub := seedActiveSubscribe(t, db, referee.Id, baseExpire)
|
||||
refererSub := seedActiveSubscribe(t, db, referer.Id, baseExpire)
|
||||
|
||||
// 先前已有订单,IsNew=false(模拟先买订单后绑码的场景)
|
||||
logic.handleCommission(context.Background(), referee, &order.Order{
|
||||
OrderNo: "ORD-FLOW-002",
|
||||
Type: OrderTypeSubscribe,
|
||||
IsNew: false, // 已有历史订单
|
||||
Amount: 100,
|
||||
FeeAmount: 0,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
|
||||
assertExpireIncreasedByDays(t, db, refereeSub.Id, baseExpire, 0)
|
||||
assertExpireIncreasedByDays(t, db, refererSub.Id, baseExpire, 0)
|
||||
}
|
||||
|
||||
func setupInviteTestLogic(t *testing.T, inviteCfg config.InviteConfig) (*ActivateOrderLogic, *gorm.DB, func()) {
|
||||
t.Helper()
|
||||
|
||||
mysqlAddr := getenvDefault("TEST_MYSQL_ADDR", "127.0.0.1:3306")
|
||||
mysqlUser := getenvDefault("TEST_MYSQL_USER", "root")
|
||||
mysqlPassword := getenvDefault("TEST_MYSQL_PASSWORD", "rootpassword")
|
||||
|
||||
adminDSN := fmt.Sprintf("%s:%s@tcp(%s)/?charset=utf8mb4&parseTime=true&loc=Local&multiStatements=true", mysqlUser, mysqlPassword, mysqlAddr)
|
||||
adminDB, err := gorm.Open(mysql.Open(adminDSN), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open mysql admin connection failed: %v", err)
|
||||
}
|
||||
|
||||
dbName := fmt.Sprintf("ppanel_test_invite_%d", time.Now().UnixNano())
|
||||
if err := adminDB.Exec(fmt.Sprintf("CREATE DATABASE `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci", dbName)).Error; err != nil {
|
||||
t.Fatalf("create test database failed: %v", err)
|
||||
}
|
||||
|
||||
testDSN := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=true&loc=Local", mysqlUser, mysqlPassword, mysqlAddr, dbName)
|
||||
db, err := gorm.Open(mysql.Open(testDSN), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open test database failed: %v", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&user.User{}, &user.Device{}, &user.AuthMethods{}, &user.Subscribe{}, &modelLog.SystemLog{}); err != nil {
|
||||
t.Fatalf("auto migrate failed: %v", err)
|
||||
}
|
||||
|
||||
redisAddr := getenvDefault("TEST_REDIS_ADDR", "127.0.0.1:6379")
|
||||
redisPassword := getenvDefault("TEST_REDIS_PASSWORD", "")
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: redisAddr,
|
||||
Password: redisPassword,
|
||||
DB: 0,
|
||||
})
|
||||
if err := rdb.Ping(context.Background()).Err(); err != nil {
|
||||
t.Fatalf("connect redis failed: %v", err)
|
||||
}
|
||||
_ = rdb.FlushDB(context.Background()).Err()
|
||||
|
||||
svcCtx := &svc.ServiceContext{
|
||||
DB: db,
|
||||
Redis: rdb,
|
||||
UserModel: user.NewModel(db, rdb),
|
||||
LogModel: modelLog.NewModel(db),
|
||||
Config: config.Config{
|
||||
Invite: inviteCfg,
|
||||
},
|
||||
}
|
||||
|
||||
return NewActivateOrderLogic(svcCtx), db, func() {
|
||||
_ = rdb.Close()
|
||||
sqlDB, _ := db.DB()
|
||||
if sqlDB != nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
_ = adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS `%s`", dbName)).Error
|
||||
}
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, db *gorm.DB, referralPercentage uint8, onlyFirstPurchase bool) *user.User {
|
||||
t.Helper()
|
||||
u := &user.User{
|
||||
Password: "pwd",
|
||||
Algo: "default",
|
||||
ReferralPercentage: referralPercentage,
|
||||
OnlyFirstPurchase: boolPtr(onlyFirstPurchase),
|
||||
Enable: boolPtr(true),
|
||||
IsAdmin: boolPtr(false),
|
||||
EnableBalanceNotify: boolPtr(false),
|
||||
EnableLoginNotify: boolPtr(false),
|
||||
EnableSubscribeNotify: boolPtr(false),
|
||||
EnableTradeNotify: boolPtr(false),
|
||||
}
|
||||
if err := db.Create(u).Error; err != nil {
|
||||
t.Fatalf("seed user failed: %v", err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func seedActiveSubscribe(t *testing.T, db *gorm.DB, userID int64, expireAt time.Time) *user.Subscribe {
|
||||
t.Helper()
|
||||
sub := &user.Subscribe{
|
||||
UserId: userID,
|
||||
OrderId: 1,
|
||||
SubscribeId: 1,
|
||||
StartTime: time.Now().Add(-24 * time.Hour),
|
||||
ExpireTime: expireAt,
|
||||
Traffic: 1024,
|
||||
Token: fmt.Sprintf("token-%d-%d", userID, time.Now().UnixNano()),
|
||||
UUID: fmt.Sprintf("uuid-%d-%d", userID, time.Now().UnixNano()),
|
||||
Status: 1,
|
||||
}
|
||||
if err := db.Create(sub).Error; err != nil {
|
||||
t.Fatalf("seed subscribe failed: %v", err)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
func assertExpireIncreasedByDays(t *testing.T, db *gorm.DB, subscribeID int64, before time.Time, days int) {
|
||||
t.Helper()
|
||||
var after user.Subscribe
|
||||
if err := db.First(&after, subscribeID).Error; err != nil {
|
||||
t.Fatalf("query subscribe failed: %v", err)
|
||||
}
|
||||
expected := before.Add(time.Duration(days) * 24 * time.Hour)
|
||||
if !after.ExpireTime.Equal(expected) {
|
||||
t.Fatalf("expire time mismatch, expected=%v got=%v", expected, after.ExpireTime)
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
func getenvDefault(key, fallback string) string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package orderLogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
modelOrder "github.com/perfect-panel/server/internal/model/order"
|
||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func setupActivationEligibilityDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
sqls := []string{
|
||||
`CREATE TABLE IF NOT EXISTS "user" (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
password VARCHAR(100) NOT NULL DEFAULT '',
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME,
|
||||
deleted_at DATETIME DEFAULT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS "user_device" (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
identifier VARCHAR(255) NOT NULL DEFAULT '' UNIQUE,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS "user_family" (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_user_id INTEGER NOT NULL DEFAULT 0,
|
||||
status TINYINT NOT NULL DEFAULT 1,
|
||||
deleted_at DATETIME DEFAULT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS "user_family_member" (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
family_id INTEGER NOT NULL DEFAULT 0,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
role TINYINT NOT NULL DEFAULT 0,
|
||||
status TINYINT NOT NULL DEFAULT 0,
|
||||
join_source VARCHAR(32) NOT NULL DEFAULT '',
|
||||
deleted_at DATETIME DEFAULT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS "order" (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
order_no VARCHAR(255) NOT NULL DEFAULT '' UNIQUE,
|
||||
type TINYINT NOT NULL DEFAULT 1,
|
||||
status TINYINT NOT NULL DEFAULT 1,
|
||||
subscribe_id INTEGER NOT NULL DEFAULT 0,
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
)`,
|
||||
}
|
||||
for _, sql := range sqls {
|
||||
require.NoError(t, db.Exec(sql).Error)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func insertActivationUser(t *testing.T, db *gorm.DB, userID int64, createdAt time.Time) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(
|
||||
`INSERT INTO "user" (id, created_at, updated_at) VALUES (?, ?, datetime('now'))`,
|
||||
userID,
|
||||
createdAt.UTC().Format("2006-01-02 15:04:05"),
|
||||
).Error)
|
||||
}
|
||||
|
||||
func insertActivationDevice(t *testing.T, db *gorm.DB, userID int64, identifier string, createdAt time.Time) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(
|
||||
`INSERT INTO "user_device" (user_id, identifier, created_at, updated_at) VALUES (?, ?, ?, datetime('now'))`,
|
||||
userID,
|
||||
identifier,
|
||||
createdAt.UTC().Format("2006-01-02 15:04:05"),
|
||||
).Error)
|
||||
}
|
||||
|
||||
func insertActivationFamily(t *testing.T, db *gorm.DB, familyID, ownerUserID int64) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(
|
||||
`INSERT INTO "user_family" (id, owner_user_id, status) VALUES (?, ?, 1)`,
|
||||
familyID,
|
||||
ownerUserID,
|
||||
).Error)
|
||||
}
|
||||
|
||||
func insertActivationFamilyMember(t *testing.T, db *gorm.DB, familyID, userID int64, role, status uint8, joinSource string) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(
|
||||
`INSERT INTO "user_family_member" (family_id, user_id, role, status, join_source) VALUES (?, ?, ?, ?, ?)`,
|
||||
familyID,
|
||||
userID,
|
||||
role,
|
||||
status,
|
||||
joinSource,
|
||||
).Error)
|
||||
}
|
||||
|
||||
func insertActivationOrder(t *testing.T, db *gorm.DB, orderNo string, userID, subscribeID int64, status uint8) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(
|
||||
`INSERT INTO "order" (user_id, order_no, type, status, subscribe_id, quantity, created_at, updated_at)
|
||||
VALUES (?, ?, 1, ?, ?, 1, datetime('now'), datetime('now'))`,
|
||||
userID,
|
||||
orderNo,
|
||||
status,
|
||||
subscribeID,
|
||||
).Error)
|
||||
}
|
||||
|
||||
func TestValidateNewUserOnlyEligibilityAtActivation_UsesEarliestBoundDeviceTime(t *testing.T) {
|
||||
db := setupActivationEligibilityDB(t)
|
||||
|
||||
const (
|
||||
ownerUserID = int64(1)
|
||||
memberUserID = int64(2)
|
||||
familyID = int64(10)
|
||||
subscribeID = int64(100)
|
||||
)
|
||||
|
||||
insertActivationUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
|
||||
insertActivationUser(t, db, memberUserID, time.Now().Add(-72*time.Hour))
|
||||
insertActivationDevice(t, db, memberUserID, "activation-old-device", time.Now().Add(-72*time.Hour))
|
||||
insertActivationFamily(t, db, familyID, ownerUserID)
|
||||
insertActivationFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
|
||||
insertActivationFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
|
||||
|
||||
err := validateNewUserOnlyEligibilityAtActivation(
|
||||
context.Background(),
|
||||
db,
|
||||
&modelOrder.Order{
|
||||
UserId: ownerUserID,
|
||||
OrderNo: "activation-check-old-device",
|
||||
Type: OrderTypeSubscribe,
|
||||
Quantity: 1,
|
||||
SubscribeId: subscribeID,
|
||||
},
|
||||
&subscribe.Subscribe{
|
||||
Id: subscribeID,
|
||||
Discount: `[{"quantity":1,"discount":90,"new_user_only":true}]`,
|
||||
},
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "is not a new user")
|
||||
}
|
||||
|
||||
func TestValidateNewUserOnlyEligibilityAtActivation_SharesHistoryAcrossBoundScope(t *testing.T) {
|
||||
db := setupActivationEligibilityDB(t)
|
||||
|
||||
const (
|
||||
ownerUserID = int64(11)
|
||||
memberUserID = int64(12)
|
||||
familyID = int64(20)
|
||||
subscribeID = int64(200)
|
||||
)
|
||||
|
||||
insertActivationUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
|
||||
insertActivationUser(t, db, memberUserID, time.Now().Add(-2*time.Hour))
|
||||
insertActivationDevice(t, db, memberUserID, "activation-shared-device", time.Now().Add(-2*time.Hour))
|
||||
insertActivationFamily(t, db, familyID, ownerUserID)
|
||||
insertActivationFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
|
||||
insertActivationFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
|
||||
insertActivationOrder(t, db, "previous-finished-order", memberUserID, subscribeID, OrderStatusFinished)
|
||||
|
||||
err := validateNewUserOnlyEligibilityAtActivation(
|
||||
context.Background(),
|
||||
db,
|
||||
&modelOrder.Order{
|
||||
UserId: ownerUserID,
|
||||
OrderNo: "current-paid-order",
|
||||
Type: OrderTypeSubscribe,
|
||||
Quantity: 1,
|
||||
SubscribeId: subscribeID,
|
||||
},
|
||||
&subscribe.Subscribe{
|
||||
Id: subscribeID,
|
||||
Discount: `[{"quantity":1,"discount":90,"new_user_only":true}]`,
|
||||
},
|
||||
)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "already activated")
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func validateNewUserOnlyEligibilityAtActivation(
|
||||
ctx,
|
||||
db,
|
||||
eligibility.ScopeUserIDs,
|
||||
orderInfo.SubscribeId,
|
||||
0,
|
||||
[]int64{OrderStatusFinished},
|
||||
orderInfo.OrderNo,
|
||||
)
|
||||
@@ -51,7 +51,7 @@ func validateNewUserOnlyEligibilityAtActivation(
|
||||
return fmt.Errorf("new user only: check history error: %w", err)
|
||||
}
|
||||
if historyCount >= 1 {
|
||||
return fmt.Errorf("new user only: user %d already activated subscribe %d", orderInfo.UserId, orderInfo.SubscribeId)
|
||||
return fmt.Errorf("new user only: user %d already activated an order", orderInfo.UserId)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user