hi-server/internal/logic/public/order/purchaseLogic.go
shanshanzhong 3167465865
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
fix: 单订阅模式成员购买时用家庭主 ID 查锚点订阅
ResolvePurchaseRoute 传入 entitlement.EffectiveUserID
(家庭主 ID)而非 u.Id(成员 ID),确保成员购买时
能找到家庭主已有订阅并续费,而不是新建一条订阅。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-31 06:08:28 -07:00

404 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,
entitlement.EffectiveUserID,
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, closing old order and creating new one",
logger.Field("user_id", u.Id),
logger.Field("old_order_no", existPending.OrderNo),
logger.Field("subscribe_id", targetSubscribeID),
)
if closeErr := NewCloseOrderLogic(l.ctx, l.svcCtx).CloseOrder(&types.CloseOrderRequest{
OrderNo: existPending.OrderNo,
}); closeErr != nil {
l.Errorw("[Purchase] close old pending order failed",
logger.Field("error", closeErr.Error()),
logger.Field("old_order_no", existPending.OrderNo),
)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "close old pending order error: %v", closeErr.Error())
}
}
}
// 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")
}
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
}
var discount float64 = 1
if len(newUserDiscount.Discounts) > 0 {
discount = getDiscount(newUserDiscount.Discounts, req.Quantity, newUserDiscount.EligibleForDiscount)
}
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")
}
}
// 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 !txNewUserDiscount.WithinWindow {
return errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNewUserOnly), "not a new user")
}
}
// 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
}