hi-server/internal/logic/public/order/purchaseLogic.go
shanshanzhong 8398865bd3 fix: SingleModel 下首购 isNew 判断错误导致佣金不发
SingleModel 模式下,用户首次购买会被路由成 orderType=2(续费),
导致 isNew 判断逻辑跳过,始终为 false,激活时 shouldProcessCommission
误判为非首购,佣金不发给邀请人。

修复:去除 isNew 查询对 orderType==1 的依赖,始终通过
IsUserEligibleForNewOrder 判断用户是否有历史完成订单。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-28 11:28:02 -07:00

400 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package order
import (
"context"
"encoding/json"
"math"
"time"
"github.com/google/uuid"
commonLogic "github.com/perfect-panel/server/internal/logic/common"
"github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/pkg/constant"
"github.com/hibiken/asynq"
"github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/pkg/xerr"
queue "github.com/perfect-panel/server/queue/types"
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
)
type PurchaseLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
const (
CloseOrderTimeMinutes = 15
)
// NewPurchaseLogic creates a new purchase logic instance for subscription purchase operations.
// It initializes the logger with context and sets up the service context for database operations.
func NewPurchaseLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PurchaseLogic {
return &PurchaseLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
// Purchase processes new subscription purchase orders including validation, discount calculation,
// coupon processing, gift amount deduction, fee calculation, and order creation with database transaction.
// It handles the complete purchase workflow from user validation to order creation and task scheduling.
func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.PurchaseOrderResponse, err error) {
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
logger.Error("current user is not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// Resolve entitlement: member's subscription goes to owner
entitlement, entErr := commonLogic.ResolveEntitlementUser(l.ctx, l.svcCtx.DB, u.Id)
if entErr != nil {
return nil, entErr
}
if req.Quantity <= 0 {
l.Debugf("[Purchase] Quantity is less than or equal to 0, setting to 1")
req.Quantity = 1
}
// Validate quantity limit
if req.Quantity > MaxQuantity {
l.Errorw("[Purchase] Quantity exceeds maximum limit", logger.Field("quantity", req.Quantity), logger.Field("max", MaxQuantity))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "quantity exceeds maximum limit of %d", MaxQuantity)
}
targetSubscribeID := req.SubscribeId
orderType := uint8(1)
parentOrderID := int64(0)
subscribeToken := ""
anchorUserSubscribeID := int64(0)
isSingleModeRenewal := false
decision, routeErr := commonLogic.ResolvePurchaseRoute(
l.ctx,
l.svcCtx.Config.Subscribe.SingleModel,
u.Id,
req.SubscribeId,
l.svcCtx.UserModel.FindSingleModeAnchorSubscribe,
)
switch {
case errors.Is(routeErr, commonLogic.ErrSingleModePlanMismatch):
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SingleSubscribePlanMismatch), "single subscribe mode plan mismatch")
case errors.Is(routeErr, gorm.ErrRecordNotFound):
case routeErr != nil:
l.Errorw("[Purchase] Database query error", logger.Field("error", routeErr.Error()), logger.Field("user_id", u.Id))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find single mode anchor subscribe error: %v", routeErr.Error())
case decision != nil:
targetSubscribeID = decision.ResolvedSubscribeID
isSingleModeRenewal = decision.Route == commonLogic.PurchaseRoutePurchaseToRenewal
if isSingleModeRenewal && decision.Anchor != nil {
orderType = 2
parentOrderID = decision.Anchor.OrderId
subscribeToken = decision.Anchor.Token
anchorUserSubscribeID = decision.Anchor.Id
l.Infow("[Purchase] single mode purchase routed to renewal",
logger.Field("mode", "single"),
logger.Field("route", "purchase_to_renewal"),
logger.Field("anchor_user_subscribe_id", decision.Anchor.Id),
logger.Field("order_no", "pending"),
logger.Field("user_id", u.Id),
)
}
}
// 单订阅模式下,若已有同套餐 pending 订单,直接返回,防止重复创建
if l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 {
var existPending order.Order
if e := l.svcCtx.DB.WithContext(l.ctx).
Model(&order.Order{}).
Where("user_id = ? AND subscribe_id = ? AND status = 1", u.Id, targetSubscribeID).
Order("id DESC").
First(&existPending).Error; e == nil && existPending.Id > 0 {
l.Infow("[Purchase] single mode pending order exists, returning existing",
logger.Field("user_id", u.Id),
logger.Field("order_no", existPending.OrderNo),
logger.Field("subscribe_id", targetSubscribeID),
)
return &types.PurchaseOrderResponse{
OrderNo: existPending.OrderNo,
AppAccountToken: existPending.AppAccountToken,
}, nil
}
}
// find subscribe plan
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, targetSubscribeID)
if err != nil {
l.Errorw("[Purchase] Database query error", logger.Field("error", err.Error()), logger.Field("subscribe_id", targetSubscribeID))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe error: %v", err.Error())
}
// check subscribe plan status
if !*sub.Sell {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "subscribe not sell")
}
// check subscribe plan inventory for new purchase flow only
if orderType == 1 && sub.Inventory == 0 {
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 sub.Discount != "" {
var dis []types.SubscribeDiscount
_ = json.Unmarshal([]byte(sub.Discount), &dis)
discount = getDiscount(dis, req.Quantity, isNewUserForDiscount)
}
price := sub.UnitPrice * req.Quantity
// discount amount
amount := int64(math.Round(float64(price) * discount))
discountAmount := price - amount
// Validate amount to prevent overflow
if amount > MaxOrderAmount {
l.Errorw("[Purchase] Order amount exceeds maximum limit",
logger.Field("amount", amount),
logger.Field("max", MaxOrderAmount),
logger.Field("user_id", u.Id),
logger.Field("subscribe_id", req.SubscribeId))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "order amount exceeds maximum limit")
}
var coupon int64 = 0
// Calculate the coupon deduction
if req.Coupon != "" {
couponInfo, err := l.svcCtx.CouponModel.FindOneByCode(l.ctx, req.Coupon)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponNotExist), "coupon not found")
}
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find coupon error: %v", err.Error())
}
if couponInfo.Count != 0 && couponInfo.Count <= couponInfo.UsedCount {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponInsufficientUsage), "coupon used")
}
couponSub := tool.StringToInt64Slice(couponInfo.Subscribe)
if len(couponSub) > 0 && !tool.Contains(couponSub, targetSubscribeID) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponNotApplicable), "coupon not match")
}
var count int64
err = l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
return tx.Model(&order.Order{}).Where("user_id = ? and coupon = ?", u.Id, req.Coupon).Count(&count).Error
})
if err != nil {
l.Errorw("[Purchase] Database query error", logger.Field("error", err.Error()), logger.Field("user_id", u.Id), logger.Field("coupon", req.Coupon))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find coupon error: %v", err.Error())
}
if count >= couponInfo.UserLimit {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.CouponInsufficientUsage), "coupon limit exceeded")
}
coupon = calculateCoupon(amount, couponInfo)
}
// Calculate the handling fee
amount -= coupon
// find payment method
payment, err := l.svcCtx.PaymentModel.FindOne(l.ctx, req.Payment)
if err != nil {
l.Errorw("[Purchase] Database query error", logger.Field("error", err.Error()), logger.Field("payment", req.Payment))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find payment method error: %v", err.Error())
}
var feeAmount int64
// Calculate the handling fee
if amount > 0 {
feeAmount = calculateFee(amount, payment)
amount += feeAmount
// Final validation after adding fee
if amount > MaxOrderAmount {
l.Errorw("[Purchase] Final order amount exceeds maximum limit after fee",
logger.Field("amount", amount),
logger.Field("max", MaxOrderAmount),
logger.Field("user_id", u.Id))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "order amount exceeds maximum limit")
}
}
// Calculate gift amount deduction after fee calculation
var deductionAmount int64
if u.GiftAmount > 0 && amount > 0 {
if u.GiftAmount >= amount {
deductionAmount = amount
amount = 0
} else {
deductionAmount = u.GiftAmount
amount -= u.GiftAmount
}
}
// query user is new purchase or renewal
// 注意SingleModel 下 orderType 会被路由成 2续费但仍需正确判断是否首购以发佣金
isNew, err := l.svcCtx.OrderModel.IsUserEligibleForNewOrder(l.ctx, u.Id)
if err != nil {
l.Errorw("[Purchase] Database query error", logger.Field("error", err.Error()), logger.Field("user_id", u.Id))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find user order error: %v", err.Error())
}
// create order
orderInfo := &order.Order{
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(),
}
if isSingleModeRenewal {
l.Infow("[Purchase] single mode purchase order created as renewal",
logger.Field("mode", "single"),
logger.Field("route", "purchase_to_renewal"),
logger.Field("anchor_user_subscribe_id", anchorUserSubscribeID),
logger.Field("order_no", orderInfo.OrderNo),
logger.Field("parent_id", orderInfo.ParentId),
logger.Field("user_id", u.Id),
)
}
// Database transaction
err = l.svcCtx.DB.Transaction(func(db *gorm.DB) error {
// 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(&currentUserSub).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 {
count++
}
}
if count >= sub.Quota {
return errors.Wrapf(xerr.NewErrCode(xerr.SubscribeQuotaLimit), "quota limit")
}
}
// check new user only restriction inside transaction to prevent race condition (tier-level only)
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)
}
if txNewUserOnly {
if time.Since(u.CreatedAt) > 24*time.Hour {
return errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNewUserOnly), "not a new user")
}
var historyCount int64
if e := 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 {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "check new user purchase history error: %v", e.Error())
}
if historyCount >= 1 {
return errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNewUserOnly), "already purchased new user plan")
}
}
}
// update user gift amount and create deduction record
if orderInfo.GiftAmount > 0 {
// deduct gift amount from user
u.GiftAmount -= orderInfo.GiftAmount
if e := l.svcCtx.UserModel.Update(l.ctx, u, db); e != nil {
l.Errorw("[Purchase] Database update error", logger.Field("error", e.Error()), logger.Field("user", u))
return e
}
// create deduction record
giftLog := log.Gift{
Type: log.GiftTypeReduce,
OrderNo: orderInfo.OrderNo,
SubscribeId: 0,
Amount: orderInfo.GiftAmount,
Balance: u.GiftAmount,
Remark: "Purchase order deduction",
Timestamp: time.Now().UnixMilli(),
}
content, _ := giftLog.Marshal()
if e := db.Model(&log.SystemLog{}).Create(&log.SystemLog{
Type: log.TypeGift.Uint8(),
Date: time.Now().Format(time.DateOnly),
ObjectID: u.Id,
Content: string(content),
}).Error; e != nil {
l.Errorw("[Purchase] Database insert error",
logger.Field("error", e.Error()),
logger.Field("deductionLog", giftLog),
)
return e
}
}
if orderInfo.Type == 1 && sub.Inventory != -1 {
// decrease subscribe plan stock
sub.Inventory -= 1
// update subscribe plan stock
if err = l.svcCtx.SubscribeModel.Update(l.ctx, sub, db); err != nil {
l.Errorw("[Purchase] Database update error", logger.Field("error", err.Error()), logger.Field("subscribe", sub))
return err
}
}
// insert order
return db.WithContext(l.ctx).Model(&order.Order{}).Create(&orderInfo).Error
})
if err != nil {
l.Errorw("[Purchase] Database insert error", logger.Field("error", err.Error()), logger.Field("orderInfo", orderInfo))
// Propagate business errors (e.g. SubscribeNewUserOnly, SubscribeQuotaLimit) directly.
var codeErr *xerr.CodeError
if errors.As(err, &codeErr) {
return nil, err
}
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert order error: %v", err.Error())
}
// Deferred task
payload := queue.DeferCloseOrderPayload{
OrderNo: orderInfo.OrderNo,
}
val, err := json.Marshal(payload)
if err != nil {
l.Errorw("[Purchase] Marshal payload error", logger.Field("error", err.Error()), logger.Field("payload", payload))
}
task := asynq.NewTask(queue.DeferCloseOrder, val, asynq.MaxRetry(3))
taskInfo, err := l.svcCtx.Queue.Enqueue(task, asynq.ProcessIn(CloseOrderTimeMinutes*time.Minute))
if err != nil {
l.Errorw("[Purchase] Enqueue task error", logger.Field("error", err.Error()), logger.Field("task", task))
} else {
l.Infow("[Purchase] Enqueue task success", logger.Field("TaskID", taskInfo.ID))
}
return &types.PurchaseOrderResponse{
OrderNo: orderInfo.OrderNo,
AppAccountToken: orderInfo.AppAccountToken,
}, nil
}