This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
modelOrder "github.com/perfect-panel/server/internal/model/order"
|
||||
modelUser "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
NewUserEligibilitySourceDeviceCreatedAt = "device_created_at"
|
||||
NewUserEligibilitySourceUserCreatedAtFallback = "user_created_at_fallback"
|
||||
NewUserEligibilityJoinSourceBindEmail = "bind_email_with_verification"
|
||||
NewUserEligibilityWindow = 24 * time.Hour
|
||||
)
|
||||
|
||||
type NewUserEligibilityContext struct {
|
||||
ScopeUserIDs []int64
|
||||
EligibilityStartAt time.Time
|
||||
Source string
|
||||
}
|
||||
|
||||
func (c *NewUserEligibilityContext) IsNewUserAt(now time.Time) bool {
|
||||
if c == nil || c.EligibilityStartAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
return now.Sub(c.EligibilityStartAt) <= NewUserEligibilityWindow
|
||||
}
|
||||
|
||||
type newUserEligibilityRelation struct {
|
||||
FamilyID int64 `gorm:"column:family_id"`
|
||||
Role uint8 `gorm:"column:role"`
|
||||
JoinSource string `gorm:"column:join_source"`
|
||||
OwnerUserID int64 `gorm:"column:owner_user_id"`
|
||||
}
|
||||
|
||||
func ResolveNewUserEligibility(ctx context.Context, db *gorm.DB, currentUserID int64) (*NewUserEligibilityContext, error) {
|
||||
if currentUserID <= 0 {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "current user id is empty")
|
||||
}
|
||||
|
||||
scopeUserIDs, err := resolveNewUserEligibilityScope(ctx, db, currentUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
startAt, source, err := resolveNewUserEligibilityStartAt(ctx, db, scopeUserIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &NewUserEligibilityContext{
|
||||
ScopeUserIDs: scopeUserIDs,
|
||||
EligibilityStartAt: startAt,
|
||||
Source: source,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func CountScopedSubscribePurchaseOrders(
|
||||
ctx context.Context,
|
||||
db *gorm.DB,
|
||||
scopeUserIDs []int64,
|
||||
subscribeID int64,
|
||||
statuses []int64,
|
||||
excludeOrderNo string,
|
||||
) (int64, error) {
|
||||
if len(scopeUserIDs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var count int64
|
||||
query := db.WithContext(ctx).
|
||||
Model(&modelOrder.Order{}).
|
||||
Where("user_id IN ? AND subscribe_id = ? AND type = 1", scopeUserIDs, subscribeID)
|
||||
if len(statuses) > 0 {
|
||||
query = query.Where("status IN ?", statuses)
|
||||
}
|
||||
if excludeOrderNo != "" {
|
||||
query = query.Where("order_no != ?", excludeOrderNo)
|
||||
}
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return 0, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "count scoped subscribe purchase orders failed")
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func resolveNewUserEligibilityScope(ctx context.Context, db *gorm.DB, currentUserID int64) ([]int64, error) {
|
||||
defaultScope := []int64{currentUserID}
|
||||
|
||||
var relation newUserEligibilityRelation
|
||||
err := db.WithContext(ctx).
|
||||
Table("user_family_member AS ufm").
|
||||
Select("ufm.family_id, ufm.role, ufm.join_source, uf.owner_user_id").
|
||||
Joins("JOIN user_family AS uf ON uf.id = ufm.family_id AND uf.deleted_at IS NULL AND uf.status = ?", modelUser.FamilyStatusActive).
|
||||
Where("ufm.user_id = ? AND ufm.deleted_at IS NULL AND ufm.status = ?", currentUserID, modelUser.FamilyMemberActive).
|
||||
Limit(1).
|
||||
Take(&relation).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return defaultScope, nil
|
||||
}
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query new user eligibility family relation failed")
|
||||
}
|
||||
|
||||
if relation.Role != modelUser.FamilyRoleOwner && relation.JoinSource != NewUserEligibilityJoinSourceBindEmail {
|
||||
return defaultScope, nil
|
||||
}
|
||||
|
||||
var scopedUserIDs []int64
|
||||
if err = db.WithContext(ctx).
|
||||
Table("user_family_member AS ufm").
|
||||
Select("ufm.user_id").
|
||||
Joins("JOIN user_family AS uf ON uf.id = ufm.family_id AND uf.deleted_at IS NULL AND uf.status = ?", modelUser.FamilyStatusActive).
|
||||
Where(
|
||||
"ufm.family_id = ? AND ufm.deleted_at IS NULL AND ufm.status = ? AND (ufm.role = ? OR ufm.join_source = ?)",
|
||||
relation.FamilyID,
|
||||
modelUser.FamilyMemberActive,
|
||||
modelUser.FamilyRoleOwner,
|
||||
NewUserEligibilityJoinSourceBindEmail,
|
||||
).
|
||||
Pluck("ufm.user_id", &scopedUserIDs).Error; err != nil {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query new user eligibility scope failed")
|
||||
}
|
||||
|
||||
scopedUserIDs = append(scopedUserIDs, currentUserID)
|
||||
return uniqueSortedInt64(scopedUserIDs), nil
|
||||
}
|
||||
|
||||
func resolveNewUserEligibilityStartAt(ctx context.Context, db *gorm.DB, scopeUserIDs []int64) (time.Time, string, error) {
|
||||
var earliestDevice modelUser.Device
|
||||
err := db.WithContext(ctx).
|
||||
Model(&modelUser.Device{}).
|
||||
Where("user_id IN ?", scopeUserIDs).
|
||||
Order("created_at ASC").
|
||||
Limit(1).
|
||||
Take(&earliestDevice).Error
|
||||
switch {
|
||||
case err == nil && !earliestDevice.CreatedAt.IsZero():
|
||||
return earliestDevice.CreatedAt, NewUserEligibilitySourceDeviceCreatedAt, nil
|
||||
case err != nil && !errors.Is(err, gorm.ErrRecordNotFound):
|
||||
return time.Time{}, "", errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query new user eligibility device start failed")
|
||||
}
|
||||
|
||||
var earliestUser modelUser.User
|
||||
err = db.WithContext(ctx).
|
||||
Model(&modelUser.User{}).
|
||||
Where("id IN ?", scopeUserIDs).
|
||||
Order("created_at ASC").
|
||||
Limit(1).
|
||||
Take(&earliestUser).Error
|
||||
if err != nil {
|
||||
return time.Time{}, "", errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query new user eligibility fallback start failed")
|
||||
}
|
||||
if earliestUser.CreatedAt.IsZero() {
|
||||
return time.Time{}, "", errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "new user eligibility start time not found")
|
||||
}
|
||||
|
||||
return earliestUser.CreatedAt, NewUserEligibilitySourceUserCreatedAtFallback, nil
|
||||
}
|
||||
|
||||
func uniqueSortedInt64(values []int64) []int64 {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
result := make([]int64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i] < result[j]
|
||||
})
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type newUserDiscountEligibility struct {
|
||||
Eligibility *commonLogic.NewUserEligibilityContext
|
||||
Discounts []types.SubscribeDiscount
|
||||
NewUserOnly bool
|
||||
WithinWindow bool
|
||||
EligibleForDiscount bool
|
||||
}
|
||||
|
||||
func resolveNewUserDiscountEligibility(
|
||||
ctx context.Context,
|
||||
db *gorm.DB,
|
||||
currentUserID int64,
|
||||
subscribeID int64,
|
||||
quantity int64,
|
||||
discountJSON string,
|
||||
) (*newUserDiscountEligibility, error) {
|
||||
state := &newUserDiscountEligibility{}
|
||||
if discountJSON == "" {
|
||||
return state, nil
|
||||
}
|
||||
|
||||
_ = json.Unmarshal([]byte(discountJSON), &state.Discounts)
|
||||
state.NewUserOnly = isNewUserOnlyForQuantity(state.Discounts, quantity)
|
||||
|
||||
eligibility, err := commonLogic.ResolveNewUserEligibility(ctx, db, currentUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state.Eligibility = eligibility
|
||||
state.WithinWindow = eligibility.IsNewUserAt(time.Now())
|
||||
state.EligibleForDiscount = state.WithinWindow
|
||||
|
||||
if state.EligibleForDiscount && state.NewUserOnly {
|
||||
historyCount, countErr := commonLogic.CountScopedSubscribePurchaseOrders(
|
||||
ctx,
|
||||
db,
|
||||
eligibility.ScopeUserIDs,
|
||||
subscribeID,
|
||||
[]int64{2, 5},
|
||||
"",
|
||||
)
|
||||
if countErr != nil {
|
||||
return nil, countErr
|
||||
}
|
||||
if historyCount >= 1 {
|
||||
state.EligibleForDiscount = false
|
||||
}
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
@@ -2,9 +2,7 @@ package order
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/model/order"
|
||||
@@ -105,47 +103,21 @@ func (l *PreCreateOrderLogic) PreCreateOrder(req *types.PurchaseOrderRequest) (r
|
||||
}
|
||||
}
|
||||
|
||||
// check new user only restriction (tier-level only)
|
||||
// Only block users who registered more than 24h ago; users within 24h who already purchased
|
||||
// are allowed to preview (they just won't receive the new-user discount).
|
||||
var newUserOnly bool
|
||||
if !isSingleModeRenewal && sub.Discount != "" {
|
||||
var dis []types.SubscribeDiscount
|
||||
_ = json.Unmarshal([]byte(sub.Discount), &dis)
|
||||
newUserOnly = isNewUserOnlyForQuantity(dis, req.Quantity)
|
||||
newUserDiscount, err := resolveNewUserDiscountEligibility(l.ctx, l.svcCtx.DB, u.Id, targetSubscribeID, req.Quantity, sub.Discount)
|
||||
if err != nil {
|
||||
l.Errorw("[PreCreateOrder] Database query error resolving new user eligibility",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("user_id", u.Id),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
if newUserOnly {
|
||||
if time.Since(u.CreatedAt) > 24*time.Hour {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNewUserOnly), "not a new user")
|
||||
}
|
||||
if !isSingleModeRenewal && newUserDiscount.NewUserOnly && !newUserDiscount.WithinWindow {
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNewUserOnly), "not a new user")
|
||||
}
|
||||
|
||||
var discount float64 = 1
|
||||
isNewUserForDiscount := time.Since(u.CreatedAt) <= 24*time.Hour
|
||||
if isNewUserForDiscount && sub.Discount != "" {
|
||||
// If the matched tier is new_user_only, check whether the user has already purchased this plan.
|
||||
// If so, they are not eligible for the new-user discount (but the order is still allowed).
|
||||
var dis []types.SubscribeDiscount
|
||||
_ = json.Unmarshal([]byte(sub.Discount), &dis)
|
||||
if isNewUserOnlyForQuantity(dis, req.Quantity) {
|
||||
var historyCount int64
|
||||
if e := l.svcCtx.DB.Model(&order.Order{}).
|
||||
Where("user_id = ? AND subscribe_id = ? AND type = 1 AND status IN ?",
|
||||
u.Id, targetSubscribeID, []int64{2, 5}).
|
||||
Count(&historyCount).Error; e != nil {
|
||||
l.Errorw("[PreCreateOrder] Database query error checking new user discount eligibility",
|
||||
logger.Field("error", e.Error()), logger.Field("user_id", u.Id))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "check new user discount history error: %v", e.Error())
|
||||
}
|
||||
if historyCount >= 1 {
|
||||
isNewUserForDiscount = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if sub.Discount != "" {
|
||||
var dis []types.SubscribeDiscount
|
||||
_ = json.Unmarshal([]byte(sub.Discount), &dis)
|
||||
discount = getDiscount(dis, req.Quantity, isNewUserForDiscount)
|
||||
if len(newUserDiscount.Discounts) > 0 {
|
||||
discount = getDiscount(newUserDiscount.Discounts, req.Quantity, newUserDiscount.EligibleForDiscount)
|
||||
}
|
||||
price := sub.UnitPrice * req.Quantity
|
||||
|
||||
|
||||
@@ -153,32 +153,18 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SubscribeOutOfStock), "subscribe out of stock")
|
||||
}
|
||||
|
||||
var discount float64 = 1
|
||||
isNewUserForDiscount := time.Since(u.CreatedAt) <= 24*time.Hour
|
||||
if isNewUserForDiscount && sub.Discount != "" {
|
||||
// If the matched tier is new_user_only, check whether the user has already purchased this plan.
|
||||
// If so, they are not eligible for the new-user discount (but the order is still allowed).
|
||||
var dis []types.SubscribeDiscount
|
||||
_ = json.Unmarshal([]byte(sub.Discount), &dis)
|
||||
if isNewUserOnlyForQuantity(dis, req.Quantity) {
|
||||
var historyCount int64
|
||||
if e := l.svcCtx.DB.Model(&order.Order{}).
|
||||
Where("user_id = ? AND subscribe_id = ? AND type = 1 AND status IN ?",
|
||||
u.Id, targetSubscribeID, []int{2, 5}).
|
||||
Count(&historyCount).Error; e != nil {
|
||||
l.Errorw("[Purchase] Database query error checking new user discount eligibility",
|
||||
logger.Field("error", e.Error()), logger.Field("user_id", u.Id))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "check new user discount history error: %v", e.Error())
|
||||
}
|
||||
if historyCount >= 1 {
|
||||
isNewUserForDiscount = false
|
||||
}
|
||||
}
|
||||
newUserDiscount, err := resolveNewUserDiscountEligibility(l.ctx, l.svcCtx.DB, u.Id, targetSubscribeID, req.Quantity, sub.Discount)
|
||||
if err != nil {
|
||||
l.Errorw("[Purchase] Database query error resolving new user eligibility",
|
||||
logger.Field("error", err.Error()),
|
||||
logger.Field("user_id", u.Id),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
if sub.Discount != "" {
|
||||
var dis []types.SubscribeDiscount
|
||||
_ = json.Unmarshal([]byte(sub.Discount), &dis)
|
||||
discount = getDiscount(dis, req.Quantity, isNewUserForDiscount)
|
||||
|
||||
var discount float64 = 1
|
||||
if len(newUserDiscount.Discounts) > 0 {
|
||||
discount = getDiscount(newUserDiscount.Discounts, req.Quantity, newUserDiscount.EligibleForDiscount)
|
||||
}
|
||||
price := sub.UnitPrice * req.Quantity
|
||||
// discount amount
|
||||
@@ -272,23 +258,23 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
||||
UserId: u.Id,
|
||||
SubscriptionUserId: entitlement.EffectiveUserID,
|
||||
ParentId: parentOrderID,
|
||||
OrderNo: tool.GenerateTradeNo(),
|
||||
Type: orderType,
|
||||
Quantity: req.Quantity,
|
||||
Price: price,
|
||||
Amount: amount,
|
||||
Discount: discountAmount,
|
||||
GiftAmount: deductionAmount,
|
||||
Coupon: req.Coupon,
|
||||
CouponDiscount: coupon,
|
||||
PaymentId: payment.Id,
|
||||
Method: canonicalOrderMethod(payment.Platform),
|
||||
FeeAmount: feeAmount,
|
||||
Status: 1,
|
||||
IsNew: isNew,
|
||||
SubscribeId: targetSubscribeID,
|
||||
SubscribeToken: subscribeToken,
|
||||
AppAccountToken: uuid.New().String(),
|
||||
OrderNo: tool.GenerateTradeNo(),
|
||||
Type: orderType,
|
||||
Quantity: req.Quantity,
|
||||
Price: price,
|
||||
Amount: amount,
|
||||
Discount: discountAmount,
|
||||
GiftAmount: deductionAmount,
|
||||
Coupon: req.Coupon,
|
||||
CouponDiscount: coupon,
|
||||
PaymentId: payment.Id,
|
||||
Method: canonicalOrderMethod(payment.Platform),
|
||||
FeeAmount: feeAmount,
|
||||
Status: 1,
|
||||
IsNew: isNew,
|
||||
SubscribeId: targetSubscribeID,
|
||||
SubscribeToken: subscribeToken,
|
||||
AppAccountToken: uuid.New().String(),
|
||||
}
|
||||
if isSingleModeRenewal {
|
||||
l.Infow("[Purchase] single mode purchase order created as renewal",
|
||||
@@ -320,20 +306,21 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
||||
}
|
||||
}
|
||||
|
||||
// check new user only restriction inside transaction to prevent race condition (tier-level only)
|
||||
// Only block users who registered more than 24h ago; users within 24h who already purchased
|
||||
// are allowed to place another order (they just won't receive the new-user discount).
|
||||
if orderInfo.Type == 1 {
|
||||
var txNewUserOnly bool
|
||||
if sub.Discount != "" {
|
||||
var dis []types.SubscribeDiscount
|
||||
_ = json.Unmarshal([]byte(sub.Discount), &dis)
|
||||
txNewUserOnly = isNewUserOnlyForQuantity(dis, orderInfo.Quantity)
|
||||
// Re-check new-user-only restriction inside the transaction to prevent race conditions.
|
||||
if orderInfo.Type == 1 && newUserDiscount.NewUserOnly {
|
||||
txNewUserDiscount, txErr := resolveNewUserDiscountEligibility(
|
||||
l.ctx,
|
||||
db,
|
||||
u.Id,
|
||||
targetSubscribeID,
|
||||
orderInfo.Quantity,
|
||||
sub.Discount,
|
||||
)
|
||||
if txErr != nil {
|
||||
return txErr
|
||||
}
|
||||
if txNewUserOnly {
|
||||
if time.Since(u.CreatedAt) > 24*time.Hour {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNewUserOnly), "not a new user")
|
||||
}
|
||||
if !txNewUserDiscount.WithinWindow {
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNewUserOnly), "not a new user")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user