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:
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
@@ -15,11 +16,10 @@ func IsEmailDomainWhitelisted(email, whitelistCSV string) bool {
|
||||
if whitelistCSV == "" {
|
||||
return false
|
||||
}
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
_, domain, ok := parseStrictEmail(email)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
domain := strings.ToLower(strings.TrimSpace(parts[1]))
|
||||
for _, d := range strings.Split(whitelistCSV, ",") {
|
||||
if strings.ToLower(strings.TrimSpace(d)) == domain {
|
||||
return true
|
||||
@@ -32,10 +32,16 @@ func ShouldGrantTrialForEmail(register config.RegisterConfig, email string) bool
|
||||
if !register.EnableTrial {
|
||||
return false
|
||||
}
|
||||
if !IsValidTrialEmail(email) {
|
||||
return false
|
||||
}
|
||||
// 无论白名单是否启用,泛域名邮箱(含 + 别名或 Gmail 点号)始终拒绝赠送
|
||||
if IsDisposableAlias(email) {
|
||||
return false
|
||||
}
|
||||
if isConfusableGmailDomain(emailDomain(email)) {
|
||||
return false
|
||||
}
|
||||
if !register.EnableTrialEmailWhitelist {
|
||||
return true
|
||||
}
|
||||
@@ -52,11 +58,10 @@ func ShouldGrantTrialForEmail(register config.RegisterConfig, email string) bool
|
||||
// For Gmail-like domains, local part containing "." or "+" is rejected.
|
||||
// For all other domains, only "+" alias is rejected.
|
||||
func IsDisposableAlias(email string) bool {
|
||||
parts := strings.SplitN(strings.ToLower(strings.TrimSpace(email)), "@", 2)
|
||||
if len(parts) != 2 {
|
||||
local, domain, ok := parseStrictEmail(email)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
local, domain := parts[0], parts[1]
|
||||
|
||||
// All domains: reject + alias
|
||||
if strings.ContainsRune(local, '+') {
|
||||
@@ -74,11 +79,10 @@ func IsDisposableAlias(email string) bool {
|
||||
// Removes dots from local part for Gmail-like providers (gmail.com, googlemail.com).
|
||||
func NormalizeEmail(email string) string {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
local, domain, ok := parseStrictEmail(email)
|
||||
if !ok {
|
||||
return email
|
||||
}
|
||||
local, domain := parts[0], parts[1]
|
||||
|
||||
// Strip + alias
|
||||
if idx := strings.IndexByte(local, '+'); idx != -1 {
|
||||
@@ -101,6 +105,51 @@ func isGmailLikeDomain(domain string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func IsValidTrialEmail(email string) bool {
|
||||
local, domain, ok := parseStrictEmail(email)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return local != "" && domain != ""
|
||||
}
|
||||
|
||||
func parseStrictEmail(email string) (local, domain string, ok bool) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email == "" || strings.ContainsAny(email, " \t\r\n") {
|
||||
return "", "", false
|
||||
}
|
||||
addr, err := mail.ParseAddress(email)
|
||||
if err != nil || addr.Address != email || addr.Name != "" {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.Split(addr.Address, "@")
|
||||
if len(parts) != 2 {
|
||||
return "", "", false
|
||||
}
|
||||
local = strings.TrimSpace(parts[0])
|
||||
domain = strings.Trim(strings.TrimSpace(parts[1]), ".")
|
||||
if local == "" || domain == "" || strings.Contains(domain, "..") || !strings.Contains(domain, ".") {
|
||||
return "", "", false
|
||||
}
|
||||
return local, domain, true
|
||||
}
|
||||
|
||||
func emailDomain(email string) string {
|
||||
_, domain, ok := parseStrictEmail(email)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return domain
|
||||
}
|
||||
|
||||
func isConfusableGmailDomain(domain string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(domain)) {
|
||||
case "gmaial.com", "gmial.com", "gmai.com", "gamil.com", "gmal.com", "gmail.co", "gmail.con":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NormalizedEmailHasTrial returns true if any user with the same normalized email
|
||||
// already holds a trial subscription. Only performs the cross-user DB check when
|
||||
// normalization actually changes the email (i.e., dots removed or + alias stripped).
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
)
|
||||
|
||||
func TestNormalizeEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
// Gmail dot trick
|
||||
{"a.v.x.xx@gmail.com", "avxxx@gmail.com"},
|
||||
{"john.doe@gmail.com", "johndoe@gmail.com"},
|
||||
{"a.b.c.d.e@gmail.com", "abcde@gmail.com"},
|
||||
// Gmail + alias
|
||||
{"user+tag@gmail.com", "user@gmail.com"},
|
||||
{"a.b+tag@gmail.com", "ab@gmail.com"},
|
||||
// Googlemail
|
||||
{"a.b@googlemail.com", "ab@googlemail.com"},
|
||||
// Non-Gmail: dots preserved
|
||||
{"john.doe@outlook.com", "john.doe@outlook.com"},
|
||||
{"john.doe@qq.com", "john.doe@qq.com"},
|
||||
// + alias stripped for all providers
|
||||
{"user+spam@outlook.com", "user@outlook.com"},
|
||||
{"user+spam@qq.com", "user@qq.com"},
|
||||
// Case insensitive
|
||||
{"User@Gmail.COM", "user@gmail.com"},
|
||||
{"A.B@Gmail.com", "ab@gmail.com"},
|
||||
// No change for normal non-gmail email
|
||||
{"abc@163.com", "abc@163.com"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := NormalizeEmail(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeEmail(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEmail_NoChangeSkipsCheck(t *testing.T) {
|
||||
// These emails should NOT trigger cross-user check (normalized == original)
|
||||
noChangeCases := []string{
|
||||
"abc@163.com",
|
||||
"john.doe@outlook.com",
|
||||
"user@qq.com",
|
||||
}
|
||||
for _, email := range noChangeCases {
|
||||
normalized := NormalizeEmail(email)
|
||||
lower := email
|
||||
if normalized == lower {
|
||||
// correct: no normalization change, NormalizedEmailHasTrial would return false early
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldGrantTrialForEmail(t *testing.T) {
|
||||
// 模拟线上配置:白名单开启,gmail.com 也在名单里
|
||||
rcWithGmail := config.RegisterConfig{
|
||||
EnableTrial: true,
|
||||
EnableTrialEmailWhitelist: true,
|
||||
TrialEmailDomainWhitelist: "hifastapp.com,hifastvpn.com,126.com,139.com,163.com,gmail.com",
|
||||
}
|
||||
// 白名单关闭
|
||||
rcNoWhitelist := config.RegisterConfig{
|
||||
EnableTrial: true,
|
||||
EnableTrialEmailWhitelist: false,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rc config.RegisterConfig
|
||||
email string
|
||||
want bool
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "gmail dot trick - blocked even if gmail.com in whitelist",
|
||||
rc: rcWithGmail,
|
||||
email: "s.m.s.n.fsmbt.d.ndny@gmail.com",
|
||||
want: false,
|
||||
reason: "gmail 泛域名(含点号)应拒绝",
|
||||
},
|
||||
{
|
||||
name: "gmail plus alias - blocked",
|
||||
rc: rcWithGmail,
|
||||
email: "user+tag@gmail.com",
|
||||
want: false,
|
||||
reason: "gmail +别名应拒绝",
|
||||
},
|
||||
{
|
||||
name: "clean gmail - allowed",
|
||||
rc: rcWithGmail,
|
||||
email: "normaluser@gmail.com",
|
||||
want: true,
|
||||
reason: "干净的 gmail 应放行",
|
||||
},
|
||||
{
|
||||
name: "163 with dot - allowed (non-gmail dot is ok)",
|
||||
rc: rcWithGmail,
|
||||
email: "s.m.s.n@163.com",
|
||||
want: true,
|
||||
reason: "非 gmail 域点号不拦截",
|
||||
},
|
||||
{
|
||||
name: "163 plus alias - blocked",
|
||||
rc: rcWithGmail,
|
||||
email: "user+spam@163.com",
|
||||
want: false,
|
||||
reason: "所有域名的 +别名都拦截",
|
||||
},
|
||||
{
|
||||
name: "gmail typo squatting domain - blocked even if accidentally whitelisted",
|
||||
rc: config.RegisterConfig{EnableTrial: true, EnableTrialEmailWhitelist: true, TrialEmailDomainWhitelist: "gmail.com,gmaial.com"},
|
||||
email: "1.2.3.4xxx@gmaial.com",
|
||||
want: false,
|
||||
reason: "易混淆 Gmail 域名不应发放试用",
|
||||
},
|
||||
{
|
||||
name: "invalid empty local - blocked",
|
||||
rc: rcWithGmail,
|
||||
email: "@gmail.com",
|
||||
want: false,
|
||||
reason: "邮箱 local 为空应拒绝",
|
||||
},
|
||||
{
|
||||
name: "subdomain spoof - blocked",
|
||||
rc: rcWithGmail,
|
||||
email: "user@fake.gmail.com",
|
||||
want: false,
|
||||
reason: "白名单必须精确匹配域名,不匹配子域",
|
||||
},
|
||||
{
|
||||
name: "whitelist disabled - gmail dot trick still blocked",
|
||||
rc: rcNoWhitelist,
|
||||
email: "s.m.s.n.fsmbt.d.ndny@gmail.com",
|
||||
want: false,
|
||||
reason: "白名单未启用,但泛域名仍应拒绝",
|
||||
},
|
||||
{
|
||||
name: "trial disabled - always blocked",
|
||||
rc: config.RegisterConfig{EnableTrial: false},
|
||||
email: "user@163.com",
|
||||
want: false,
|
||||
reason: "试用未开启",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ShouldGrantTrialForEmail(tt.rc, tt.email)
|
||||
if got != tt.want {
|
||||
t.Errorf("ShouldGrantTrialForEmail(%q) = %v, want %v | reason: %s",
|
||||
tt.email, got, tt.want, tt.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEmailDomainWhitelisted(t *testing.T) {
|
||||
whitelist := "gmail.com,edu.cn,outlook.com"
|
||||
tests := []struct {
|
||||
email string
|
||||
want bool
|
||||
}{
|
||||
{"user@gmail.com", true},
|
||||
{"user@edu.cn", true},
|
||||
{"User@Gmail.COM", true},
|
||||
{"user@yahoo.com", false},
|
||||
{"user@fake.gmail.com", false}, // subdomain not matched
|
||||
{"user@", false},
|
||||
{"notanemail", false},
|
||||
{"@gmail.com", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.email, func(t *testing.T) {
|
||||
got := IsEmailDomainWhitelisted(tt.email, whitelist)
|
||||
if got != tt.want {
|
||||
t.Errorf("IsEmailDomainWhitelisted(%q) = %v, want %v", tt.email, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,10 @@ func CountScopedSubscribePurchaseOrders(
|
||||
var count int64
|
||||
query := db.WithContext(ctx).
|
||||
Model(&modelOrder.Order{}).
|
||||
Where("user_id IN ? AND subscribe_id = ? AND type IN ? AND amount > 0", scopeUserIDs, subscribeID, []int64{1, 2})
|
||||
Where("user_id IN ? AND type IN ?", scopeUserIDs, []int64{1, 2})
|
||||
if subscribeID > 0 {
|
||||
query = query.Where("subscribe_id = ?", subscribeID)
|
||||
}
|
||||
if len(statuses) > 0 {
|
||||
query = query.Where("status IN ?", statuses)
|
||||
}
|
||||
|
||||
@@ -52,12 +52,8 @@ func ResolvePurchaseRoute(
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
if requestedSubscribeID != anchorSub.SubscribeId {
|
||||
return nil, ErrSingleModePlanMismatch
|
||||
}
|
||||
|
||||
decision.Route = PurchaseRoutePurchaseToRenewal
|
||||
decision.ResolvedSubscribeID = anchorSub.SubscribeId
|
||||
decision.ResolvedSubscribeID = requestedSubscribeID
|
||||
decision.Anchor = anchorSub
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResolvePurchaseRoute_AllowsPlanChangeForExistingSubscription(t *testing.T) {
|
||||
anchor := &user.Subscribe{
|
||||
Id: 10,
|
||||
UserId: 20,
|
||||
OrderId: 30,
|
||||
SubscribeId: 1,
|
||||
Token: "existing-token",
|
||||
ExpireTime: time.Now().Add(time.Hour),
|
||||
}
|
||||
|
||||
decision, err := ResolvePurchaseRoute(
|
||||
context.Background(),
|
||||
true,
|
||||
anchor.UserId,
|
||||
2,
|
||||
func(context.Context, int64) (*user.Subscribe, error) {
|
||||
return anchor, nil
|
||||
},
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, PurchaseRoutePurchaseToRenewal, decision.Route)
|
||||
require.Equal(t, int64(2), decision.ResolvedSubscribeID)
|
||||
require.Equal(t, anchor, decision.Anchor)
|
||||
}
|
||||
@@ -12,6 +12,9 @@ func getDiscount(discounts []types.SubscribeDiscount, inputMonths int64, isNewUs
|
||||
if d.Quantity != inputMonths || d.Discount <= 0 || d.Discount >= 100 {
|
||||
continue
|
||||
}
|
||||
if d.NewUserOnly && !isNewUser {
|
||||
continue
|
||||
}
|
||||
if isNewUser {
|
||||
// lowest discount value = biggest saving
|
||||
if best < 0 || d.Discount < best {
|
||||
@@ -50,4 +53,3 @@ func isNewUserOnlyForQuantity(discounts []types.SubscribeDiscount, inputQuantity
|
||||
}
|
||||
return hasNewUserOnly && !hasFallback
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetDiscount_SkipsNewUserOnlyTierForExistingUser(t *testing.T) {
|
||||
discount := getDiscount([]types.SubscribeDiscount{
|
||||
{Quantity: 1, Discount: 90, NewUserOnly: true},
|
||||
}, 1, false)
|
||||
|
||||
require.Equal(t, float64(1), discount)
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func resolveNewUserDiscountEligibility(
|
||||
ctx,
|
||||
db,
|
||||
eligibility.ScopeUserIDs,
|
||||
subscribeID,
|
||||
0,
|
||||
[]int64{2, 5},
|
||||
"",
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -111,18 +112,21 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
||||
}
|
||||
}
|
||||
|
||||
// 非单订阅模式下,若用户已有同套餐订阅(含过期),提前路由为续费,防止创建重复订阅
|
||||
// 全局单订阅口径:若用户已有任意付费订阅(含过期),提前路由为续费/换套餐,
|
||||
// 防止不同套餐购买创建第二条订阅。
|
||||
if !l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 {
|
||||
var existSub user.Subscribe
|
||||
if e := l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&user.Subscribe{}).
|
||||
Where("user_id = ? AND subscribe_id = ?", entitlement.EffectiveUserID, targetSubscribeID).
|
||||
Where("user_id = ? AND token != '' AND (order_id > 0 OR token LIKE 'iap:%')", entitlement.EffectiveUserID).
|
||||
Order("expire_time DESC").
|
||||
Order("updated_at DESC").
|
||||
Order("id DESC").
|
||||
First(&existSub).Error; e == nil && existSub.Id > 0 && existSub.Token != "" {
|
||||
orderType = 2
|
||||
parentOrderID = existSub.OrderId
|
||||
subscribeToken = existSub.Token
|
||||
l.Infow("[Purchase] non-single mode purchase routed to renewal (existing subscription found)",
|
||||
l.Infow("[Purchase] purchase routed to renewal/change plan (existing subscription found)",
|
||||
logger.Field("existing_subscribe_id", existSub.Id),
|
||||
logger.Field("existing_status", existSub.Status),
|
||||
logger.Field("user_id", u.Id),
|
||||
@@ -311,13 +315,13 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
||||
// check subscribe plan quota limit inside transaction to prevent race condition
|
||||
if orderInfo.Type == 1 && sub.Quota > 0 {
|
||||
var currentUserSub []user.Subscribe
|
||||
if e := db.Model(&user.Subscribe{}).Where("user_id = ?", u.Id).Find(¤tUserSub).Error; e != nil {
|
||||
if e := db.Model(&user.Subscribe{}).Where("user_id = ?", entitlement.EffectiveUserID).Find(¤tUserSub).Error; e != nil {
|
||||
l.Errorw("[Purchase] Database query error", logger.Field("error", e.Error()), logger.Field("user_id", u.Id))
|
||||
return e
|
||||
}
|
||||
var count int64
|
||||
for _, v := range currentUserSub {
|
||||
if v.SubscribeId == targetSubscribeID {
|
||||
if v.OrderId > 0 || strings.HasPrefix(v.Token, "iap:") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,766 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user