package order import ( "context" "testing" "time" "github.com/alicebob/miniredis/v2" "github.com/hibiken/asynq" modelOrder "github.com/perfect-panel/server/internal/model/order" "github.com/perfect-panel/server/internal/model/payment" subModel "github.com/perfect-panel/server/internal/model/subscribe" "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/perfect-panel/server/pkg/xerr" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" ) // setupNewUserOnlyDB 创建带必要表的 SQLite 内存数据库 func setupNewUserOnlyDB(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, "failed to open in-memory SQLite") db.Exec("PRAGMA foreign_keys = OFF") sqls := []string{ `CREATE TABLE IF NOT EXISTS "subscribe" ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255) NOT NULL DEFAULT '', language VARCHAR(255) NOT NULL DEFAULT '', description TEXT, unit_price INTEGER NOT NULL DEFAULT 0, unit_time VARCHAR(255) NOT NULL DEFAULT '', discount TEXT, replacement INTEGER NOT NULL DEFAULT 0, inventory INTEGER NOT NULL DEFAULT -1, traffic INTEGER NOT NULL DEFAULT 0, speed_limit INTEGER NOT NULL DEFAULT 0, device_limit INTEGER NOT NULL DEFAULT 0, quota INTEGER NOT NULL DEFAULT 0, new_user_only TINYINT DEFAULT 0, nodes VARCHAR(255), node_tags VARCHAR(255), show TINYINT NOT NULL DEFAULT 0, sell TINYINT NOT NULL DEFAULT 1, sort INTEGER NOT NULL DEFAULT 0, deduction_ratio INTEGER DEFAULT 0, allow_deduction TINYINT DEFAULT 1, reset_cycle INTEGER DEFAULT 0, renewal_reset TINYINT DEFAULT 0, show_original_price TINYINT NOT NULL DEFAULT 1, created_at DATETIME, updated_at DATETIME )`, `CREATE TABLE IF NOT EXISTS "order" ( id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER DEFAULT NULL, user_id INTEGER NOT NULL DEFAULT 0, subscription_user_id INTEGER NOT NULL DEFAULT 0, order_no VARCHAR(255) NOT NULL DEFAULT '' UNIQUE, type TINYINT NOT NULL DEFAULT 1, quantity INTEGER NOT NULL DEFAULT 1, price INTEGER NOT NULL DEFAULT 0, amount INTEGER NOT NULL DEFAULT 0, gift_amount INTEGER NOT NULL DEFAULT 0, discount INTEGER NOT NULL DEFAULT 0, coupon VARCHAR(255) DEFAULT NULL, coupon_discount INTEGER NOT NULL DEFAULT 0, commission INTEGER NOT NULL DEFAULT 0, payment_id INTEGER NOT NULL DEFAULT 0, method VARCHAR(255) NOT NULL DEFAULT '', fee_amount INTEGER NOT NULL DEFAULT 0, trade_no VARCHAR(255) DEFAULT NULL, app_account_token VARCHAR(255) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1, subscribe_id INTEGER NOT NULL DEFAULT 0, subscribe_token VARCHAR(255) DEFAULT NULL, is_new TINYINT NOT NULL DEFAULT 0, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME DEFAULT NULL )`, `CREATE TABLE IF NOT EXISTS "user" ( id INTEGER PRIMARY KEY AUTOINCREMENT, password VARCHAR(100) NOT NULL DEFAULT '', algo VARCHAR(20) DEFAULT 'default', salt VARCHAR(20) DEFAULT NULL, avatar TEXT, balance INTEGER DEFAULT 0, refer_code VARCHAR(20) DEFAULT '', referer_id INTEGER DEFAULT 0, commission INTEGER DEFAULT 0, referral_percentage INTEGER DEFAULT 0, only_first_purchase TINYINT DEFAULT 1, gift_amount INTEGER DEFAULT 0, enable TINYINT DEFAULT 1, is_admin TINYINT DEFAULT 0, enable_balance_notify TINYINT DEFAULT 0, enable_login_notify TINYINT DEFAULT 0, enable_subscribe_notify TINYINT DEFAULT 0, enable_trade_notify TINYINT DEFAULT 0, rules TEXT, member_status VARCHAR(20) DEFAULT '', created_at DATETIME, updated_at DATETIME, deleted_at DATETIME DEFAULT NULL )`, `CREATE TABLE IF NOT EXISTS "payment" ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(100) NOT NULL DEFAULT '', platform VARCHAR(100) NOT NULL DEFAULT '', icon VARCHAR(255) DEFAULT '', domain VARCHAR(255) DEFAULT '', config TEXT NOT NULL DEFAULT '{}', description TEXT, fee_mode TINYINT NOT NULL DEFAULT 0, fee_percent INTEGER DEFAULT 0, fee_amount INTEGER DEFAULT 0, enable TINYINT NOT NULL DEFAULT 1, token VARCHAR(255) NOT NULL DEFAULT '' UNIQUE )`, `CREATE TABLE IF NOT EXISTS "user_subscribe" ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL DEFAULT 0, order_id INTEGER NOT NULL DEFAULT 0, subscribe_id INTEGER NOT NULL DEFAULT 0, start_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, expire_time DATETIME DEFAULT NULL, finished_at DATETIME DEFAULT NULL, traffic INTEGER DEFAULT 0, download INTEGER DEFAULT 0, upload INTEGER DEFAULT 0, token VARCHAR(255) DEFAULT '' UNIQUE, uuid VARCHAR(255) DEFAULT '' UNIQUE, status TINYINT DEFAULT 0, note VARCHAR(500) DEFAULT '', created_at DATETIME, updated_at DATETIME )`, `CREATE TABLE IF NOT EXISTS "user_device" ( id INTEGER PRIMARY KEY AUTOINCREMENT, ip VARCHAR(255) NOT NULL DEFAULT '', user_id INTEGER NOT NULL DEFAULT 0, user_agent TEXT, identifier VARCHAR(255) NOT NULL DEFAULT '' UNIQUE, short_code VARCHAR(255) NOT NULL DEFAULT '', online TINYINT NOT NULL DEFAULT 0, enabled TINYINT NOT NULL DEFAULT 1, 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, max_members INTEGER NOT NULL DEFAULT 2, status TINYINT DEFAULT 0, created_at DATETIME, updated_at DATETIME, 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 DEFAULT 0, status TINYINT DEFAULT 0, join_source VARCHAR(32) NOT NULL DEFAULT '', joined_at DATETIME, left_at DATETIME DEFAULT NULL, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME DEFAULT NULL )`, } for _, sql := range sqls { require.NoError(t, db.Exec(sql).Error) } return db } // setupNewUserOnlyRedis 启动 miniredis,返回 redis.Client 和 miniredis 句柄 func setupNewUserOnlyRedis(t *testing.T) (*redis.Client, *miniredis.Miniredis) { t.Helper() mr, err := miniredis.Run() require.NoError(t, err) t.Cleanup(mr.Close) rds := redis.NewClient(&redis.Options{Addr: mr.Addr()}) return rds, mr } // buildNewUserOnlySvcCtx 组装最小 ServiceContext(含 asynq Queue 使用 miniredis) func buildNewUserOnlySvcCtx(db *gorm.DB, rds *redis.Client, mr *miniredis.Miniredis) *svc.ServiceContext { queue := asynq.NewClient(asynq.RedisClientOpt{Addr: mr.Addr()}) return &svc.ServiceContext{ DB: db, Redis: rds, UserModel: user.NewModel(db, rds), OrderModel: modelOrder.NewModel(db, rds), SubscribeModel: subModel.NewModel(db, rds), PaymentModel: payment.NewModel(db, rds), Queue: queue, } } // insertTestSubscribe 直接用 SQL 插入 subscribe 行(绕过 GORM hook 的 MySQL 方言) // new_user_only=true 时同时写入 discount JSON,使代码里的 discount 检查生效 func insertTestSubscribe(t *testing.T, db *gorm.DB, id int64, newUserOnly bool) { t.Helper() nuOnly := 0 discount := "" if newUserOnly { nuOnly = 1 // discount JSON 包含一个 new_user_only=true 的 tier,匹配 quantity=1 discount = `[{"quantity":1,"discount":90,"new_user_only":true}]` } err := db.Exec(`INSERT INTO "subscribe" (id, name, unit_price, inventory, sell, sort, new_user_only, discount, created_at, updated_at) VALUES (?, 'Test Plan', 1000, -1, 1, ?, ?, ?, datetime('now'), datetime('now'))`, id, id, nuOnly, discount).Error require.NoError(t, err) } // insertTestPayment 插入支付方式行 func insertTestPayment(t *testing.T, db *gorm.DB, id int64) { t.Helper() err := db.Exec(`INSERT INTO "payment" (id, name, platform, config, enable, fee_mode, token) VALUES (?, 'Balance', 'balance', '{}', 1, 0, ?)`, id, "test-token").Error require.NoError(t, err) } // insertTestUser 插入用户行,createdAt 可控 func insertTestUser(t *testing.T, db *gorm.DB, id int64, createdAt time.Time) *user.User { t.Helper() err := db.Exec(`INSERT INTO "user" (id, password, balance, gift_amount, enable, created_at, updated_at) VALUES (?, '', 0, 0, 1, ?, datetime('now'))`, id, createdAt.UTC().Format("2006-01-02 15:04:05")).Error require.NoError(t, err) return &user.User{ Id: id, GiftAmount: 0, CreatedAt: createdAt, } } func insertTestDevice(t *testing.T, db *gorm.DB, userID int64, identifier string, createdAt time.Time) { t.Helper() err := db.Exec(`INSERT INTO "user_device" (user_id, ip, user_agent, identifier, short_code, online, enabled, created_at, updated_at) VALUES (?, '127.0.0.1', 'test-agent', ?, '', 0, 1, ?, datetime('now'))`, userID, identifier, createdAt.UTC().Format("2006-01-02 15:04:05"), ).Error require.NoError(t, err) } func insertTestFamily(t *testing.T, db *gorm.DB, familyID, ownerUserID int64) { t.Helper() err := db.Exec(`INSERT INTO "user_family" (id, owner_user_id, max_members, status, created_at, updated_at) VALUES (?, ?, 3, 1, datetime('now'), datetime('now'))`, familyID, ownerUserID, ).Error require.NoError(t, err) } func insertTestFamilyMember(t *testing.T, db *gorm.DB, familyID, userID int64, role, status uint8, joinSource string) { t.Helper() err := db.Exec(`INSERT INTO "user_family_member" (family_id, user_id, role, status, join_source, joined_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'), datetime('now'))`, familyID, userID, role, status, joinSource, ).Error require.NoError(t, err) } // insertTestOrder 插入一条历史订单(status=2 表示已支付) func insertTestOrder(t *testing.T, db *gorm.DB, userID, subscribeID int64, status uint8) { t.Helper() err := db.Exec(`INSERT INTO "order" (user_id, order_no, type, status, subscribe_id, created_at, updated_at) VALUES (?, ?, 1, ?, ?, datetime('now'), datetime('now'))`, userID, "existing-order-no", status, subscribeID).Error require.NoError(t, err) } func insertScopedTestOrder(t *testing.T, db *gorm.DB, orderNo string, userID, subscribeID int64, status uint8) { t.Helper() err := db.Exec(`INSERT INTO "order" (user_id, order_no, type, status, subscribe_id, created_at, updated_at) VALUES (?, ?, 1, ?, ?, datetime('now'), datetime('now'))`, userID, orderNo, status, subscribeID).Error require.NoError(t, err) } // buildPurchaseCtx 把 user 放入 context(模拟中间件行为) func buildPurchaseCtx(u *user.User) context.Context { return context.WithValue(context.Background(), constant.CtxKeyUser, u) } // TestPurchase_NewUserOnly_UserTooOld 验证:new_user_only=true,用户注册超过 24h → 返回 SubscribeNewUserOnly func TestPurchase_NewUserOnly_UserTooOld(t *testing.T) { db := setupNewUserOnlyDB(t) rds, mr := setupNewUserOnlyRedis(t) svcCtx := buildNewUserOnlySvcCtx(db, rds, mr) const subID = int64(1) const payID = int64(1) insertTestSubscribe(t, db, subID, true) // new_user_only = true insertTestPayment(t, db, payID) // 用户注册 48 小时前 → 超出 24h 限制 u := insertTestUser(t, db, 100, time.Now().Add(-48*time.Hour)) ctx := buildPurchaseCtx(u) logic := NewPurchaseLogic(ctx, svcCtx) _, err := logic.Purchase(&types.PurchaseOrderRequest{ SubscribeId: subID, Payment: payID, Quantity: 1, }) require.Error(t, err) var errCode *xerr.CodeError require.ErrorAs(t, err, &errCode) assert.Equal(t, xerr.SubscribeNewUserOnly, errCode.GetErrCode(), "注册超过24h应返回 SubscribeNewUserOnly 错误码") // 验证订单未被创建 var count int64 db.Model(&modelOrder.Order{}).Where("user_id = ?", u.Id).Count(&count) assert.Equal(t, int64(0), count, "用户注册超时,订单不应被创建") } // TestPurchase_NewUserOnly_AlreadyPurchased 验证:new_user_only=true,用户是新用户但已购买过 // → 允许下单(不拦截),但不享受新人折扣 func TestPurchase_NewUserOnly_AlreadyPurchased(t *testing.T) { db := setupNewUserOnlyDB(t) rds, mr := setupNewUserOnlyRedis(t) svcCtx := buildNewUserOnlySvcCtx(db, rds, mr) const subID = int64(2) const payID = int64(1) insertTestSubscribe(t, db, subID, true) insertTestPayment(t, db, payID) // 用户刚注册(2h前)→ 满足时间条件 u := insertTestUser(t, db, 200, time.Now().Add(-2*time.Hour)) // 但已有一条 status=2 的历史订单(已支付) insertTestOrder(t, db, u.Id, subID, 2) ctx := buildPurchaseCtx(u) logic := NewPurchaseLogic(ctx, svcCtx) resp, err := logic.Purchase(&types.PurchaseOrderRequest{ SubscribeId: subID, Payment: payID, Quantity: 1, }) // 不应被拦截,允许下单 require.NoError(t, err, "24h内已购用户应允许继续下单,不应返回错误") require.NotNil(t, resp) assert.NotEmpty(t, resp.OrderNo) // 历史订单 +1(新增了一条) var count int64 db.Model(&modelOrder.Order{}).Where("user_id = ?", u.Id).Count(&count) assert.Equal(t, int64(2), count, "应新增一条订单") // 新订单无折扣:Amount=Price=1000 var newOrder modelOrder.Order require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error) assert.Equal(t, int64(1000), newOrder.Amount, "已购用户不享受新人折扣,Amount 应等于 Price") assert.Equal(t, int64(0), newOrder.Discount, "Discount 应为 0") } // TestPurchase_NewUserOnly_Success 验证:new_user_only=true,新用户首次购买 → 成功创建订单 func TestPurchase_NewUserOnly_Success(t *testing.T) { db := setupNewUserOnlyDB(t) rds, mr := setupNewUserOnlyRedis(t) svcCtx := buildNewUserOnlySvcCtx(db, rds, mr) const subID = int64(3) const payID = int64(1) insertTestSubscribe(t, db, subID, true) insertTestPayment(t, db, payID) // 用户 1 小时前注册(新用户),且没有历史订单 u := insertTestUser(t, db, 300, time.Now().Add(-1*time.Hour)) ctx := buildPurchaseCtx(u) logic := NewPurchaseLogic(ctx, svcCtx) resp, err := logic.Purchase(&types.PurchaseOrderRequest{ SubscribeId: subID, Payment: payID, Quantity: 1, }) require.NoError(t, err) require.NotNil(t, resp) assert.NotEmpty(t, resp.OrderNo, "新用户首次购买应成功,返回订单号") // 验证订单已写入数据库 var o modelOrder.Order err = db.Where("order_no = ?", resp.OrderNo).First(&o).Error require.NoError(t, err) assert.Equal(t, u.Id, o.UserId) assert.Equal(t, subID, o.SubscribeId) } // TestPurchase_NewUserOnly_Disabled 验证:new_user_only=false 时,老用户也能正常购买 func TestPurchase_NewUserOnly_Disabled(t *testing.T) { db := setupNewUserOnlyDB(t) rds, mr := setupNewUserOnlyRedis(t) svcCtx := buildNewUserOnlySvcCtx(db, rds, mr) const subID = int64(4) const payID = int64(1) insertTestSubscribe(t, db, subID, false) // new_user_only = false insertTestPayment(t, db, payID) // 注册 30 天的老用户 u := insertTestUser(t, db, 400, time.Now().Add(-30*24*time.Hour)) ctx := buildPurchaseCtx(u) logic := NewPurchaseLogic(ctx, svcCtx) resp, err := logic.Purchase(&types.PurchaseOrderRequest{ SubscribeId: subID, Payment: payID, Quantity: 1, }) require.NoError(t, err) require.NotNil(t, resp) assert.NotEmpty(t, resp.OrderNo, "new_user_only=false时老用户应能正常购买") } // TestPurchase_SingleMode_PendingOldOrderCancelled 验证:单订阅模式下,已有 pending 订单时 // 第二次下单应关闭旧单并创建新单(而非复用旧单) func TestPurchase_SingleMode_PendingOldOrderCancelled(t *testing.T) { db := setupNewUserOnlyDB(t) rds, mr := setupNewUserOnlyRedis(t) svcCtx := buildNewUserOnlySvcCtx(db, rds, mr) svcCtx.Config.Subscribe.SingleModel = true const subID = int64(5) const payID = int64(1) insertTestSubscribe(t, db, subID, false) insertTestPayment(t, db, payID) u := insertTestUser(t, db, 500, time.Now().Add(-1*time.Hour)) ctx := buildPurchaseCtx(u) // 第一次下单(pending) logic := NewPurchaseLogic(ctx, svcCtx) resp1, err := logic.Purchase(&types.PurchaseOrderRequest{ SubscribeId: subID, Payment: payID, Quantity: 1, }) require.NoError(t, err) require.NotNil(t, resp1) firstOrderNo := resp1.OrderNo assert.NotEmpty(t, firstOrderNo) // 确认第一单 pending var firstOrder modelOrder.Order require.NoError(t, db.Where("order_no = ?", firstOrderNo).First(&firstOrder).Error) assert.Equal(t, uint8(1), firstOrder.Status, "第一单应为 pending") // 第二次下单(不同 quantity) logic2 := NewPurchaseLogic(ctx, svcCtx) resp2, err := logic2.Purchase(&types.PurchaseOrderRequest{ SubscribeId: subID, Payment: payID, Quantity: 3, }) require.NoError(t, err) require.NotNil(t, resp2) secondOrderNo := resp2.OrderNo // 新单与旧单不同 assert.NotEqual(t, firstOrderNo, secondOrderNo, "第二次下单应创建新订单,不复用旧单") // 旧单应被关闭(status=3) var closedOrder modelOrder.Order require.NoError(t, db.Where("order_no = ?", firstOrderNo).First(&closedOrder).Error) assert.Equal(t, uint8(3), closedOrder.Status, "旧 pending 单应被关闭") // 新单的 quantity 应为 3 var newOrder modelOrder.Order require.NoError(t, db.Where("order_no = ?", secondOrderNo).First(&newOrder).Error) assert.Equal(t, int64(3), newOrder.Quantity, "新单 quantity 应为 3") assert.Equal(t, uint8(1), newOrder.Status, "新单应为 pending 状态") } // TestPurchase_SingleMode_NoPendingOrder 验证:单订阅模式下,没有旧 pending 单时正常创建 func TestPurchase_SingleMode_NoPendingOrder(t *testing.T) { db := setupNewUserOnlyDB(t) rds, mr := setupNewUserOnlyRedis(t) svcCtx := buildNewUserOnlySvcCtx(db, rds, mr) svcCtx.Config.Subscribe.SingleModel = true const subID = int64(6) const payID = int64(1) insertTestSubscribe(t, db, subID, false) insertTestPayment(t, db, payID) u := insertTestUser(t, db, 600, time.Now().Add(-1*time.Hour)) ctx := buildPurchaseCtx(u) logic := NewPurchaseLogic(ctx, svcCtx) resp, err := logic.Purchase(&types.PurchaseOrderRequest{ SubscribeId: subID, Payment: payID, Quantity: 2, }) require.NoError(t, err) require.NotNil(t, resp) assert.NotEmpty(t, resp.OrderNo, "无旧 pending 单时应正常创建新单") var o modelOrder.Order require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&o).Error) assert.Equal(t, int64(2), o.Quantity) assert.Equal(t, uint8(1), o.Status) } // TestPurchase_NewUserOnly_AlreadyPurchased_NoBlock 验证:new_user_only=true 套餐, // 24小时内但已购买过 → 允许下单,但不享受新人折扣(Discount=0,Amount=Price) func TestPurchase_NewUserOnly_AlreadyPurchased_NoBlock(t *testing.T) { db := setupNewUserOnlyDB(t) rds, mr := setupNewUserOnlyRedis(t) svcCtx := buildNewUserOnlySvcCtx(db, rds, mr) const subID = int64(7) const payID = int64(1) // 套餐:unit_price=1000,discount=[{quantity:1,discount:80,new_user_only:true}] insertTestSubscribe(t, db, subID, true) insertTestPayment(t, db, payID) // 用户 1 小时前注册(新用户),但已有一条成功订单(status=2) u := insertTestUser(t, db, 700, time.Now().Add(-1*time.Hour)) insertTestOrder(t, db, u.Id, subID, 2) ctx := buildPurchaseCtx(u) logic := NewPurchaseLogic(ctx, svcCtx) resp, err := logic.Purchase(&types.PurchaseOrderRequest{ SubscribeId: subID, Payment: payID, Quantity: 1, }) // 不应被拦截 require.NoError(t, err, "24h内已购用户不应被拦截,应允许下单") require.NotNil(t, resp) assert.NotEmpty(t, resp.OrderNo) // 验证订单金额:无折扣,Amount=Price=1000,Discount=0 var newOrder modelOrder.Order require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error) assert.Equal(t, int64(1000), newOrder.Price, "Price 应为原价 1000") assert.Equal(t, int64(1000), newOrder.Amount, "已购用户不享受新人折扣,Amount 应等于 Price") assert.Equal(t, int64(0), newOrder.Discount, "Discount 应为 0") } // TestPurchase_NewUserOnly_FirstPurchase_HasDiscount 验证:new_user_only=true 套餐, // 24小时内首次购买 → 允许下单且享受新人折扣 func TestPurchase_NewUserOnly_FirstPurchase_HasDiscount(t *testing.T) { db := setupNewUserOnlyDB(t) rds, mr := setupNewUserOnlyRedis(t) svcCtx := buildNewUserOnlySvcCtx(db, rds, mr) const subID = int64(8) const payID = int64(1) // 套餐:unit_price=1000,discount=[{quantity:1,discount:80,new_user_only:true}](8折) insertTestSubscribe(t, db, subID, true) insertTestPayment(t, db, payID) // 用户 1 小时前注册,无历史订单 u := insertTestUser(t, db, 800, time.Now().Add(-1*time.Hour)) ctx := buildPurchaseCtx(u) logic := NewPurchaseLogic(ctx, svcCtx) resp, err := logic.Purchase(&types.PurchaseOrderRequest{ SubscribeId: subID, Payment: payID, Quantity: 1, }) require.NoError(t, err) require.NotNil(t, resp) var newOrder modelOrder.Order require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error) assert.Equal(t, int64(1000), newOrder.Price, "Price 应为原价 1000") assert.Equal(t, int64(900), newOrder.Amount, "首次购买应享受9折,Amount=900") assert.Equal(t, int64(100), newOrder.Discount, "折扣金额应为 100") } func TestPurchase_NewUserOnly_BindEmailScopeUsesEarliestDeviceTime(t *testing.T) { db := setupNewUserOnlyDB(t) rds, mr := setupNewUserOnlyRedis(t) svcCtx := buildNewUserOnlySvcCtx(db, rds, mr) const ( subID = int64(9) payID = int64(1) ownerUserID = int64(901) memberUserID = int64(902) familyID = int64(99) ) insertTestSubscribe(t, db, subID, true) insertTestPayment(t, db, payID) owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour)) insertTestUser(t, db, memberUserID, time.Now().Add(-72*time.Hour)) insertTestDevice(t, db, memberUserID, "device-eligibility-old", time.Now().Add(-72*time.Hour)) insertTestFamily(t, db, familyID, ownerUserID) insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init") insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification") logic := NewPurchaseLogic(buildPurchaseCtx(owner), svcCtx) _, err := logic.Purchase(&types.PurchaseOrderRequest{ SubscribeId: subID, Payment: payID, Quantity: 1, }) require.Error(t, err) var errCode *xerr.CodeError require.ErrorAs(t, err, &errCode) assert.Equal(t, xerr.SubscribeNewUserOnly, errCode.GetErrCode()) } func TestPurchase_NewUserOnly_BindEmailScopeSharesHistory(t *testing.T) { db := setupNewUserOnlyDB(t) rds, mr := setupNewUserOnlyRedis(t) svcCtx := buildNewUserOnlySvcCtx(db, rds, mr) const ( subID = int64(10) payID = int64(1) ownerUserID = int64(1001) memberUserID = int64(1002) familyID = int64(109) ) insertTestSubscribe(t, db, subID, true) insertTestPayment(t, db, payID) owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour)) insertTestUser(t, db, memberUserID, time.Now().Add(-2*time.Hour)) insertTestDevice(t, db, memberUserID, "device-eligibility-shared", time.Now().Add(-2*time.Hour)) insertTestFamily(t, db, familyID, ownerUserID) insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init") insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification") insertScopedTestOrder(t, db, "existing-scope-order", memberUserID, subID, 2) logic := NewPurchaseLogic(buildPurchaseCtx(owner), svcCtx) resp, err := logic.Purchase(&types.PurchaseOrderRequest{ SubscribeId: subID, Payment: payID, Quantity: 1, }) require.NoError(t, err) require.NotNil(t, resp) var newOrder modelOrder.Order require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error) assert.Equal(t, int64(1000), newOrder.Amount) assert.Equal(t, int64(0), newOrder.Discount) } func TestPreCreateOrder_NewUserOnly_BindEmailScopeUsesEarliestDeviceTime(t *testing.T) { db := setupNewUserOnlyDB(t) rds, mr := setupNewUserOnlyRedis(t) svcCtx := buildNewUserOnlySvcCtx(db, rds, mr) const ( subID = int64(11) ownerUserID = int64(1101) memberUserID = int64(1102) familyID = int64(119) ) insertTestSubscribe(t, db, subID, true) owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour)) insertTestUser(t, db, memberUserID, time.Now().Add(-96*time.Hour)) insertTestDevice(t, db, memberUserID, "device-precreate-old", time.Now().Add(-96*time.Hour)) insertTestFamily(t, db, familyID, ownerUserID) insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init") insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification") logic := NewPreCreateOrderLogic(buildPurchaseCtx(owner), svcCtx) _, err := logic.PreCreateOrder(&types.PurchaseOrderRequest{ SubscribeId: subID, Quantity: 1, }) require.Error(t, err) var errCode *xerr.CodeError require.ErrorAs(t, err, &errCode) assert.Equal(t, xerr.SubscribeNewUserOnly, errCode.GetErrCode()) } func TestPreCreateOrder_NewUserOnly_OrdinaryFamilyMemberDoesNotAffectEligibility(t *testing.T) { db := setupNewUserOnlyDB(t) rds, mr := setupNewUserOnlyRedis(t) svcCtx := buildNewUserOnlySvcCtx(db, rds, mr) const ( subID = int64(12) ownerUserID = int64(1201) memberUserID = int64(1202) familyID = int64(129) ) insertTestSubscribe(t, db, subID, true) owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour)) insertTestUser(t, db, memberUserID, time.Now().Add(-96*time.Hour)) insertTestDevice(t, db, memberUserID, "device-precreate-ordinary", time.Now().Add(-96*time.Hour)) insertTestFamily(t, db, familyID, ownerUserID) insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init") insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "manual_invite") logic := NewPreCreateOrderLogic(buildPurchaseCtx(owner), svcCtx) resp, err := logic.PreCreateOrder(&types.PurchaseOrderRequest{ SubscribeId: subID, Quantity: 1, }) require.NoError(t, err) require.NotNil(t, resp) assert.Equal(t, int64(900), resp.Amount) assert.Equal(t, int64(100), resp.Discount) }