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