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/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(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 { totalAmount += pkg.SuggestedRetailPrice } if err := validatePackageTypeMix(packages); err != nil { return nil, 0, err } return packages, totalAmount, nil } func (h *AutoPurchaseHandler) buildOrderAndItems( 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 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, SellerShopID: sellerShopID, SeriesID: seriesID, SellerCostPrice: sellerCostPrice, } items := make([]*model.OrderItem, 0, len(packages)) for _, pkg := range packages { items = append(items, &model.OrderItem{ BaseModel: model.BaseModel{ Creator: rechargeOrder.UserID, Updater: rechargeOrder.UserID, }, PackageID: pkg.ID, PackageName: pkg.PackageName, Quantity: 1, UnitPrice: pkg.SuggestedRetailPrice, Amount: pkg.SuggestedRetailPrice, }) } return order, items, nil } 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 仅影响后台囤货路径,此处无需判断 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, Status: status, Priority: priority, DataResetCycle: pkg.DataResetCycle, PendingRealnameActivation: pendingRealnameActivation, Generation: order.Generation, PaidAmount: order.ActualPaidAmount, } 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 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, Status: constants.PackageUsageStatusActive, Priority: priority, MasterUsageID: &mainPackage.ID, ActivatedAt: &now, ExpiresAt: expiresAt, DataResetCycle: pkg.DataResetCycle, Generation: order.Generation, PaidAmount: order.ActualPaidAmount, } 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 }