Files
junhong_cmp_fiber/internal/task/auto_purchase.go
huang 66e12e9629
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m57s
订单相关以及佣金相关修复
2026-04-30 09:52:39 +08:00

696 lines
22 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 task
import (
"context"
"errors"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"github.com/break/junhong_cmp_fiber/internal/service/packageprice"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// AutoPurchasePayload 充值后自动购包任务载荷
type AutoPurchasePayload struct {
RechargeOrderID uint `json:"recharge_order_id"`
}
// AutoPurchaseHandler 充值后自动购包任务处理器
type AutoPurchaseHandler struct {
db *gorm.DB
orderStore *postgres.OrderStore
rechargeOrderStore *postgres.RechargeOrderStore
paymentStore *postgres.PaymentStore
walletStore *postgres.AssetWalletStore
walletTransactionStore *postgres.AssetWalletTransactionStore
packageUsageStore *postgres.PackageUsageStore
shopPackageAllocationStore *postgres.ShopPackageAllocationStore // 用于查询卖家成本价
iotCardStore *postgres.IotCardStore // 用于获取卡的系列ID
deviceStore *postgres.DeviceStore // 用于获取设备的系列ID
redis *redis.Client
asynqClient *asynq.Client // 用于事务提交成功后触发佣金计算任务
logger *zap.Logger
}
// NewAutoPurchaseHandler 创建充值后自动购包处理器
func NewAutoPurchaseHandler(
db *gorm.DB,
orderStore *postgres.OrderStore,
rechargeOrderStore *postgres.RechargeOrderStore,
paymentStore *postgres.PaymentStore,
walletStore *postgres.AssetWalletStore,
walletTransactionStore *postgres.AssetWalletTransactionStore,
packageUsageStore *postgres.PackageUsageStore,
redisClient *redis.Client,
asynqClient *asynq.Client,
logger *zap.Logger,
) *AutoPurchaseHandler {
if orderStore == nil {
orderStore = postgres.NewOrderStore(db, redisClient)
}
if rechargeOrderStore == nil {
rechargeOrderStore = postgres.NewRechargeOrderStore(db, redisClient)
}
if paymentStore == nil {
paymentStore = postgres.NewPaymentStore(db, redisClient)
}
if walletStore == nil {
walletStore = postgres.NewAssetWalletStore(db, redisClient)
}
if walletTransactionStore == nil {
walletTransactionStore = postgres.NewAssetWalletTransactionStore(db, redisClient)
}
if packageUsageStore == nil {
packageUsageStore = postgres.NewPackageUsageStore(db, redisClient)
}
return &AutoPurchaseHandler{
db: db,
orderStore: orderStore,
rechargeOrderStore: rechargeOrderStore,
paymentStore: paymentStore,
walletStore: walletStore,
walletTransactionStore: walletTransactionStore,
packageUsageStore: packageUsageStore,
shopPackageAllocationStore: postgres.NewShopPackageAllocationStore(db),
iotCardStore: postgres.NewIotCardStore(db, redisClient),
deviceStore: postgres.NewDeviceStore(db, redisClient),
redis: redisClient,
asynqClient: asynqClient,
logger: logger,
}
}
// ProcessTask 处理充值后自动购包任务
func (h *AutoPurchaseHandler) ProcessTask(ctx context.Context, task *asynq.Task) error {
var payload AutoPurchasePayload
if err := sonic.Unmarshal(task.Payload(), &payload); err != nil {
h.logger.Error("解析自动购包任务载荷失败", zap.Error(err))
return asynq.SkipRetry
}
if payload.RechargeOrderID == 0 {
h.logger.Error("自动购包任务载荷无效", zap.Uint("recharge_order_id", payload.RechargeOrderID))
return asynq.SkipRetry
}
rechargeOrder, err := h.rechargeOrderStore.GetByID(ctx, payload.RechargeOrderID)
if err != nil {
if err == gorm.ErrRecordNotFound {
h.logger.Warn("充值订单不存在,跳过自动购包", zap.Uint("recharge_order_id", payload.RechargeOrderID))
return asynq.SkipRetry
}
h.logger.Error("查询充值订单失败", zap.Uint("recharge_order_id", payload.RechargeOrderID), zap.Error(err))
return err
}
if rechargeOrder.AutoPurchaseStatus == constants.AutoPurchaseStatusSuccess {
return nil
}
if rechargeOrder.AutoPurchaseStatus == constants.AutoPurchaseStatusFailed {
return nil
}
packageIDs, err := parseLinkedPackageIDs(rechargeOrder.LinkedPackageIDs)
if err != nil {
h.logger.Error("解析关联套餐ID失败", zap.Uint("recharge_order_id", rechargeOrder.ID), zap.Error(err))
h.markAutoPurchaseFailedIfFinalRetry(ctx, rechargeOrder.ID)
return asynq.SkipRetry
}
if len(packageIDs) == 0 {
h.logger.Error("关联套餐ID为空无法自动购包", zap.Uint("recharge_order_id", rechargeOrder.ID))
h.markAutoPurchaseFailedIfFinalRetry(ctx, rechargeOrder.ID)
return asynq.SkipRetry
}
packages, totalAmount, err := h.loadPackages(ctx, packageIDs)
if err != nil {
h.logger.Error("加载关联套餐失败", zap.Uint("recharge_order_id", rechargeOrder.ID), zap.Error(err))
h.markAutoPurchaseFailedIfFinalRetry(ctx, rechargeOrder.ID)
return err
}
// 获取资产系列ID用于差价佣金和一次性佣金计算
var seriesID *uint
if rechargeOrder.LinkedCarrierID != nil && *rechargeOrder.LinkedCarrierID > 0 {
if rechargeOrder.LinkedCarrierType == "card" || rechargeOrder.LinkedCarrierType == constants.AssetWalletResourceTypeIotCard {
if card, cardErr := h.iotCardStore.GetByID(ctx, *rechargeOrder.LinkedCarrierID); cardErr == nil {
seriesID = card.SeriesID
} else {
h.logger.Warn("自动购包获取卡系列ID失败",
zap.Uint("card_id", *rechargeOrder.LinkedCarrierID),
zap.Error(cardErr))
}
} else if rechargeOrder.LinkedCarrierType == "device" || rechargeOrder.LinkedCarrierType == constants.AssetWalletResourceTypeDevice {
if device, deviceErr := h.deviceStore.GetByID(ctx, *rechargeOrder.LinkedCarrierID); deviceErr == nil {
seriesID = device.SeriesID
} else {
h.logger.Warn("自动购包获取设备系列ID失败",
zap.Uint("device_id", *rechargeOrder.LinkedCarrierID),
zap.Error(deviceErr))
}
}
}
// 获取卖家成本价,用于差价佣金链式计算的起点
// 平台直销ShopIDTag == 0时成本价为 0差价佣金计算会直接跳过
var sellerCostPrice int64
if rechargeOrder.ShopIDTag > 0 && len(packages) > 0 {
allocation, allocErr := h.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, rechargeOrder.ShopIDTag, packages[0].ID)
if allocErr == nil {
sellerCostPrice = allocation.CostPrice
} else {
h.logger.Warn("自动购包获取卖家成本价失败差价佣金将计算为0",
zap.Uint("shop_id", rechargeOrder.ShopIDTag),
zap.Uint("package_id", packages[0].ID),
zap.Error(allocErr))
}
}
var createdOrderID uint
if err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
wallet, walletErr := h.walletStore.GetByID(ctx, rechargeOrder.AssetWalletID)
if walletErr != nil {
if walletErr == gorm.ErrRecordNotFound {
return errors.New("资产钱包不存在")
}
return walletErr
}
if wallet.GetAvailableBalance() < totalAmount {
return errors.New("钱包余额不足")
}
if err = h.walletStore.DeductBalanceWithTx(ctx, tx, wallet.ID, totalAmount, wallet.Version); err != nil {
return err
}
now := time.Now()
order, orderItems, buildErr := h.buildOrderAndItems(ctx, rechargeOrder, packages, totalAmount, seriesID, sellerCostPrice, now)
if buildErr != nil {
return buildErr
}
if err = tx.Create(order).Error; err != nil {
return err
}
createdOrderID = order.ID
for _, item := range orderItems {
item.OrderID = order.ID
}
if err = tx.CreateInBatches(orderItems, 100).Error; err != nil {
return err
}
payment := &model.Payment{
PaymentNo: order.OrderNo,
OrderID: order.ID,
OrderType: model.PaymentOrderTypePackage,
PaymentMethod: model.PaymentByWallet,
Amount: totalAmount,
Status: model.PaymentRecordStatusPaid,
}
if err = tx.Create(payment).Error; err != nil {
return err
}
refType := constants.ReferenceTypeOrder
walletTx := &model.AssetWalletTransaction{
AssetWalletID: wallet.ID,
ResourceType: wallet.ResourceType,
ResourceID: wallet.ResourceID,
UserID: rechargeOrder.UserID,
TransactionType: constants.AssetTransactionTypeDeduct,
Amount: -totalAmount,
BalanceBefore: wallet.Balance,
BalanceAfter: wallet.Balance - totalAmount,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType,
ReferenceNo: &order.OrderNo,
Creator: rechargeOrder.UserID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err = h.walletTransactionStore.CreateWithTx(ctx, tx, walletTx); err != nil {
return err
}
if err = h.activatePackages(ctx, tx, order, packages, now); err != nil {
return err
}
if err = tx.Model(&model.RechargeOrder{}).
Where("id = ?", rechargeOrder.ID).
Update("auto_purchase_status", constants.AutoPurchaseStatusSuccess).Error; err != nil {
return err
}
return nil
}); err != nil {
h.logger.Error("自动购包任务执行失败",
zap.Uint("recharge_record_id", rechargeOrder.ID),
zap.Error(err),
)
h.markAutoPurchaseFailedIfFinalRetry(ctx, rechargeOrder.ID)
return err
}
// 事务提交成功后触发佣金计算(不在事务内,防止任务提交后事务回滚的数据一致性问题)
if h.asynqClient != nil && createdOrderID > 0 {
payloadBytes, marshalErr := sonic.Marshal(map[string]any{"order_id": createdOrderID})
if marshalErr != nil {
h.logger.Warn("佣金任务载荷序列化失败",
zap.Uint("order_id", createdOrderID),
zap.Error(marshalErr))
} else {
commissionTask := asynq.NewTask(constants.TaskTypeCommission, payloadBytes,
asynq.Queue(constants.QueueLow),
asynq.MaxRetry(3),
)
if _, enqueueErr := h.asynqClient.EnqueueContext(ctx, commissionTask); enqueueErr != nil {
h.logger.Warn("自动购包后提交佣金任务失败",
zap.Uint("order_id", createdOrderID),
zap.Error(enqueueErr))
}
}
}
h.logger.Info("自动购包任务执行成功", zap.Uint("recharge_record_id", rechargeOrder.ID))
return nil
}
// NewAutoPurchaseTask 创建充值后自动购包任务
func NewAutoPurchaseTask(rechargeOrderID uint) (*asynq.Task, error) {
payloadBytes, err := sonic.Marshal(AutoPurchasePayload{RechargeOrderID: rechargeOrderID})
if err != nil {
return nil, err
}
return asynq.NewTask(constants.TaskTypeAutoPurchaseAfterRecharge, payloadBytes,
asynq.MaxRetry(3),
asynq.Timeout(2*time.Minute),
asynq.Queue(constants.QueueDefault),
), nil
}
func (h *AutoPurchaseHandler) markAutoPurchaseFailedIfFinalRetry(ctx context.Context, rechargeOrderID uint) {
retryCount, ok := asynq.GetRetryCount(ctx)
if !ok {
return
}
maxRetry, ok := asynq.GetMaxRetry(ctx)
if !ok {
return
}
if retryCount < maxRetry-1 {
return
}
if err := h.db.WithContext(ctx).
Model(&model.RechargeOrder{}).
Where("id = ?", rechargeOrderID).
Update("auto_purchase_status", constants.AutoPurchaseStatusFailed).Error; err != nil {
h.logger.Error("更新自动购包失败状态失败",
zap.Uint("recharge_record_id", rechargeOrderID),
zap.Error(err),
)
return
}
h.logger.Warn("自动购包达到最大重试次数,已标记失败", zap.Uint("recharge_record_id", rechargeOrderID))
}
func (h *AutoPurchaseHandler) loadPackages(ctx context.Context, packageIDs []uint) ([]*model.Package, int64, error) {
packages := make([]*model.Package, 0, len(packageIDs))
if err := h.db.WithContext(ctx).Where("id IN ?", packageIDs).Find(&packages).Error; err != nil {
return nil, 0, err
}
if len(packages) != len(packageIDs) {
return nil, 0, gorm.ErrRecordNotFound
}
totalAmount := int64(0)
for _, pkg := range packages {
if pkg.IsGift {
return nil, 0, errors.New("赠送套餐不能进入自动购包链路")
}
totalAmount += packageprice.PackageEffectiveRetailPrice(pkg)
}
if err := validatePackageTypeMix(packages); err != nil {
return nil, 0, err
}
return packages, totalAmount, nil
}
func (h *AutoPurchaseHandler) buildOrderAndItems(
ctx context.Context,
rechargeOrder *model.RechargeOrder,
packages []*model.Package,
totalAmount int64,
seriesID *uint,
sellerCostPrice int64,
now time.Time,
) (*model.Order, []*model.OrderItem, error) {
orderType, iotCardID, deviceID, err := parseLinkedCarrier(rechargeOrder.LinkedOrderType, rechargeOrder.LinkedCarrierType, rechargeOrder.LinkedCarrierID)
if err != nil {
return nil, nil, err
}
generation := rechargeOrder.Generation
if generation <= 0 {
generation = 1
}
var sellerShopID *uint
if rechargeOrder.ShopIDTag > 0 {
shopID := rechargeOrder.ShopIDTag
sellerShopID = &shopID
}
paidAmount := totalAmount
operatorAccountID, operatorAccountName := h.buildPersonalCustomerOperatorSnapshot(ctx, rechargeOrder.UserID)
order := &model.Order{
BaseModel: model.BaseModel{
Creator: rechargeOrder.UserID,
Updater: rechargeOrder.UserID,
},
OrderNo: h.orderStore.GenerateOrderNo(),
OrderType: orderType,
BuyerType: model.BuyerTypePersonal,
BuyerID: rechargeOrder.UserID,
IotCardID: iotCardID,
DeviceID: deviceID,
TotalAmount: totalAmount,
PaymentMethod: model.PaymentMethodWallet,
PaymentStatus: model.PaymentStatusPaid,
PaidAt: &now,
CommissionStatus: model.CommissionStatusPending,
CommissionConfigVersion: 0,
Source: constants.OrderSourceClient,
Generation: generation,
ActualPaidAmount: &paidAmount,
OperatorAccountID: operatorAccountID,
OperatorAccountType: model.OperatorAccountTypePersonalCustomer,
OperatorAccountName: operatorAccountName,
SellerShopID: sellerShopID,
SeriesID: seriesID,
SellerCostPrice: sellerCostPrice,
}
items := make([]*model.OrderItem, 0, len(packages))
for _, pkg := range packages {
unitPrice := packageprice.PackageEffectiveRetailPrice(pkg)
items = append(items, &model.OrderItem{
BaseModel: model.BaseModel{
Creator: rechargeOrder.UserID,
Updater: rechargeOrder.UserID,
},
PackageID: pkg.ID,
PackageName: pkg.PackageName,
Quantity: 1,
UnitPrice: unitPrice,
Amount: unitPrice,
PackagePriceConfigStatus: pkg.PriceConfigStatus,
PackageIsGift: pkg.IsGift,
})
}
return order, items, nil
}
func (h *AutoPurchaseHandler) buildPersonalCustomerOperatorSnapshot(ctx context.Context, customerID uint) (*uint, string) {
if customerID == 0 {
return nil, ""
}
var customer model.PersonalCustomer
if err := h.db.WithContext(ctx).Where("id = ?", customerID).First(&customer).Error; err != nil {
id := customerID
return &id, ""
}
id := customerID
return &id, customer.Nickname
}
func (h *AutoPurchaseHandler) activatePackages(
ctx context.Context,
tx *gorm.DB,
order *model.Order,
packages []*model.Package,
now time.Time,
) error {
carrierType := constants.AssetWalletResourceTypeIotCard
carrierID := uint(0)
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
carrierID = *order.IotCardID
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
carrierType = constants.AssetWalletResourceTypeDevice
carrierID = *order.DeviceID
} else {
return errors.New("无效的订单载体")
}
for _, pkg := range packages {
var existingUsage model.PackageUsage
err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&existingUsage).Error
if err == nil {
continue
}
if err != gorm.ErrRecordNotFound {
return err
}
if pkg.PackageType == constants.PackageTypeFormal {
if err = h.activateMainPackage(ctx, tx, order, pkg, carrierType, carrierID, now); err != nil {
return err
}
continue
}
if pkg.PackageType == constants.PackageTypeAddon {
if err = h.activateAddonPackage(ctx, tx, order, pkg, carrierType, carrierID, now); err != nil {
return err
}
}
}
return nil
}
func (h *AutoPurchaseHandler) activateMainPackage(
ctx context.Context,
tx *gorm.DB,
order *model.Order,
pkg *model.Package,
carrierType string,
carrierID uint,
now time.Time,
) error {
_ = ctx
var activeMainPackage model.PackageUsage
err := tx.Where("status = ?", constants.PackageUsageStatusActive).
Where("master_usage_id IS NULL").
Where(carrierType+"_id = ?", carrierID).
Order("priority ASC").
First(&activeMainPackage).Error
hasActiveMain := err == nil
var status int
var priority int
var activatedAt time.Time
var expiresAt time.Time
var nextResetAt *time.Time
var pendingRealnameActivation bool
if hasActiveMain {
status = constants.PackageUsageStatusPending
var maxPriority int
tx.Model(&model.PackageUsage{}).
Where(carrierType+"_id = ?", carrierID).
Select("COALESCE(MAX(priority), 0)").
Scan(&maxPriority)
priority = maxPriority + 1
} else {
status = constants.PackageUsageStatusActive
priority = 1
activatedAt = now
expiresAt = packagepkg.CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
}
// REALNAME-02: 自动购包属于 C 端充值触发不需要等实名C 端已做前置实名检查)
// ExpiryBase 仅影响后台囤货路径,此处无需判断
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)
usage := &model.PackageUsage{
BaseModel: model.BaseModel{
Creator: order.Creator,
Updater: order.Creator,
},
OrderID: order.ID,
OrderNo: order.OrderNo,
PackageID: pkg.ID,
PackageName: pkg.PackageName,
UsageType: order.OrderType,
DataLimitMB: pkg.RealDataMB,
VirtualTotalMBSnapshot: virtualTotalMBSnapshot,
DisplayGainRatioSnapshot: displayGainRatioSnapshot,
EnableVirtualDataSnapshot: enableVirtualDataSnapshot,
Status: status,
Priority: priority,
DataResetCycle: pkg.DataResetCycle,
PendingRealnameActivation: pendingRealnameActivation,
Generation: order.Generation,
PaidAmount: order.ActualPaidAmount,
PackagePriceConfigStatus: pkg.PriceConfigStatus,
PackageIsGift: pkg.IsGift,
}
if carrierType == constants.AssetWalletResourceTypeIotCard {
usage.IotCardID = carrierID
} else {
usage.DeviceID = carrierID
}
if status == constants.PackageUsageStatusActive {
usage.ActivatedAt = &activatedAt
usage.ExpiresAt = &expiresAt
usage.NextResetAt = nextResetAt
}
if err = tx.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
return err
}
return tx.Model(usage).Updates(map[string]any{
"status": usage.Status,
"pending_realname_activation": usage.PendingRealnameActivation,
}).Error
}
func (h *AutoPurchaseHandler) activateAddonPackage(
ctx context.Context,
tx *gorm.DB,
order *model.Order,
pkg *model.Package,
carrierType string,
carrierID uint,
now time.Time,
) error {
_ = ctx
var mainPackage model.PackageUsage
err := tx.Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive}).
Where("master_usage_id IS NULL").
Where(carrierType+"_id = ?", carrierID).
Order("priority ASC").
First(&mainPackage).Error
if err == gorm.ErrRecordNotFound {
return errors.New("必须有主套餐才能购买加油包")
}
if err != nil {
return err
}
var maxPriority int
tx.Model(&model.PackageUsage{}).
Where(carrierType+"_id = ?", carrierID).
Select("COALESCE(MAX(priority), 0)").
Scan(&maxPriority)
priority := maxPriority + 1
expiresAt := mainPackage.ExpiresAt
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)
usage := &model.PackageUsage{
BaseModel: model.BaseModel{
Creator: order.Creator,
Updater: order.Creator,
},
OrderID: order.ID,
OrderNo: order.OrderNo,
PackageID: pkg.ID,
PackageName: pkg.PackageName,
UsageType: order.OrderType,
DataLimitMB: pkg.RealDataMB,
VirtualTotalMBSnapshot: virtualTotalMBSnapshot,
DisplayGainRatioSnapshot: displayGainRatioSnapshot,
EnableVirtualDataSnapshot: enableVirtualDataSnapshot,
Status: constants.PackageUsageStatusActive,
Priority: priority,
MasterUsageID: &mainPackage.ID,
ActivatedAt: &now,
ExpiresAt: expiresAt,
DataResetCycle: pkg.DataResetCycle,
Generation: order.Generation,
PaidAmount: order.ActualPaidAmount,
PackagePriceConfigStatus: pkg.PriceConfigStatus,
PackageIsGift: pkg.IsGift,
}
if carrierType == constants.AssetWalletResourceTypeIotCard {
usage.IotCardID = carrierID
} else {
usage.DeviceID = carrierID
}
return tx.Create(usage).Error
}
func parseLinkedPackageIDs(raw []byte) ([]uint, error) {
var packageIDs []uint
if len(raw) == 0 {
return nil, nil
}
if err := sonic.Unmarshal(raw, &packageIDs); err != nil {
return nil, err
}
return packageIDs, nil
}
func parseLinkedCarrier(linkedOrderType string, linkedCarrierType string, linkedCarrierID *uint) (string, *uint, *uint, error) {
if linkedCarrierID == nil || *linkedCarrierID == 0 {
return "", nil, nil, errors.New("关联载体ID为空")
}
if linkedOrderType == model.OrderTypeSingleCard || linkedCarrierType == "card" || linkedCarrierType == constants.AssetWalletResourceTypeIotCard {
id := *linkedCarrierID
return model.OrderTypeSingleCard, &id, nil, nil
}
if linkedOrderType == model.OrderTypeDevice || linkedCarrierType == "device" || linkedCarrierType == constants.AssetWalletResourceTypeDevice {
id := *linkedCarrierID
return model.OrderTypeDevice, nil, &id, nil
}
return "", nil, nil, errors.New("关联载体类型无效")
}
func validatePackageTypeMix(packages []*model.Package) error {
hasFormal := false
hasAddon := false
for _, pkg := range packages {
switch pkg.PackageType {
case constants.PackageTypeFormal:
hasFormal = true
case constants.PackageTypeAddon:
hasAddon = true
}
if hasFormal && hasAddon {
return errors.New("不允许在同一订单中同时购买正式套餐和加油包")
}
}
return nil
}