package packagepkg import ( "context" "time" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" ) // ResumeCallback 复机回调接口 // 用于在套餐激活或流量重置后触发自动复机 type ResumeCallback interface { ResumeCardIfStopped(ctx context.Context, carrierType string, carrierID uint) error } type ActivationService struct { db *gorm.DB redis *redis.Client packageUsageStore *postgres.PackageUsageStore packageStore *postgres.PackageStore packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore logger *zap.Logger resumeCallback ResumeCallback // 复机回调,可选 } func NewActivationService( db *gorm.DB, redis *redis.Client, packageUsageStore *postgres.PackageUsageStore, packageStore *postgres.PackageStore, packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore, logger *zap.Logger, ) *ActivationService { return &ActivationService{ db: db, redis: redis, packageUsageStore: packageUsageStore, packageStore: packageStore, packageUsageDailyRecord: packageUsageDailyRecord, logger: logger, } } // SetResumeCallback 任务 24.7: 设置复机回调 // 在应用启动时由 bootstrap 调用,注入停复机服务 func (s *ActivationService) SetResumeCallback(callback ResumeCallback) { s.resumeCallback = callback } // ActivateByRealname 任务 9.2-9.3: 首次实名激活 // 当用户完成实名后,激活所有待实名激活的套餐 func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType string, carrierID uint) error { // 查询待实名激活的套餐 var pendingUsages []*model.PackageUsage query := s.db.WithContext(ctx). Where("pending_realname_activation = ?", true). Where("status = ?", constants.PackageUsageStatusPending) if carrierType == "iot_card" { query = query.Where("iot_card_id = ?", carrierID) } else if carrierType == "device" { query = query.Where("device_id = ?", carrierID) } else { return errors.New(errors.CodeInvalidParam, "无效的载体类型") } if err := query.Order("priority ASC").Find(&pendingUsages).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询待实名激活套餐失败") } if len(pendingUsages) == 0 { s.logger.Info("没有待实名激活的套餐", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID)) return nil } now := time.Now() // 在事务中激活套餐 return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { for _, usage := range pendingUsages { // 查询套餐信息 var pkg model.Package if err := tx.First(&pkg, usage.PackageID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败") } // 检查是否是主套餐 if usage.MasterUsageID == nil { // 主套餐:需要检查是否有已激活或已用完(仍在有效期)的主套餐 var activeMain model.PackageUsage err := tx.Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}). Where("master_usage_id IS NULL"). Where(carrierType+"_id = ?", carrierID). Order("priority ASC"). First(&activeMain).Error if err == nil { // 已有生效中或已用完(占位)的主套餐,保持排队状态 s.logger.Warn("已有占位主套餐,跳过激活", zap.Uint("usage_id", usage.ID), zap.Uint("active_main_id", activeMain.ID), zap.Int("active_main_status", activeMain.Status)) continue } if err != gorm.ErrRecordNotFound { return errors.Wrap(errors.CodeDatabaseError, err, "检查生效中主套餐失败") } } // REALNAME-04: 按 ExpiryBase 选择激活计时基准 // from_purchase:购买时起算,用 PackageUsage 创建时间;否则用实名触发当前时刻 var activatedAt time.Time if pkg.ExpiryBase == "from_purchase" { activatedAt = usage.CreatedAt } else { activatedAt = now } expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays) // 计算下次重置时间 nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt) // 更新套餐使用记录 updates := map[string]interface{}{ "status": constants.PackageUsageStatusActive, "pending_realname_activation": false, "activated_at": activatedAt, "expires_at": expiresAt, } if nextResetAt != nil { updates["next_reset_at"] = *nextResetAt } if err := tx.Model(usage).Updates(updates).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "激活套餐失败") } s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID) s.logger.Info("套餐已激活", zap.Uint("usage_id", usage.ID), zap.Uint("package_id", usage.PackageID), zap.Time("activated_at", activatedAt), zap.Time("expires_at", expiresAt)) if s.resumeCallback != nil { go func(ct string, cid uint) { if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil { s.logger.Error("实名激活后自动复机失败", zap.String("carrier_type", ct), zap.Uint("carrier_id", cid), zap.Error(err)) } }(carrierType, carrierID) } } return nil }) } // ActivateQueuedPackage 任务 9.4-9.7: 排队主套餐激活 // 当主套餐过期后,激活下一个待生效的主套餐 func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierType string, carrierID uint) error { // 使用 Redis 分布式锁避免并发 lockKey := constants.RedisPackageActivationLockKey(carrierType, carrierID) lockValue := time.Now().String() locked, err := s.redis.SetNX(ctx, lockKey, lockValue, 30*time.Second).Result() if err != nil { return errors.Wrap(errors.CodeRedisError, err, "获取分布式锁失败") } if !locked { s.logger.Warn("套餐激活正在进行中,跳过", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID)) return nil } defer s.redis.Del(ctx, lockKey) return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 任务 9.5: 检测并标记过期的主套餐 now := time.Now() var expiredMainUsages []*model.PackageUsage err := tx.Where("status = ?", constants.PackageUsageStatusActive). Where("master_usage_id IS NULL"). Where("expires_at <= ?", now). Where(carrierType+"_id = ?", carrierID). Find(&expiredMainUsages).Error if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询过期主套餐失败") } for _, expiredMain := range expiredMainUsages { // 更新主套餐状态为已过期 if err := tx.Model(expiredMain).Update("status", constants.PackageUsageStatusExpired).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新过期主套餐状态失败") } expiresAt := now if expiredMain.ExpiresAt != nil { expiresAt = *expiredMain.ExpiresAt } s.logger.Info("主套餐已过期", zap.Uint("usage_id", expiredMain.ID), zap.Time("expires_at", expiresAt)) // 任务 9.7: 加油包级联失效 if err := s.invalidateAddons(ctx, tx, expiredMain.ID); err != nil { return err } // 任务 9.6: 激活下一个待生效主套餐 if err := s.activateNextMainPackage(ctx, tx, carrierType, carrierID, now); err != nil { return err } } return nil }) } // ActivateSpecificPackage 任务 4: 激活指定的套餐使用记录 // 根据 PackageUsageID 精准激活目标套餐,而非重跑"查找过期包"流程 func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, packageUsageID uint) error { // 加载 PackageUsage var usage model.PackageUsage if err := s.db.WithContext(ctx).First(&usage, packageUsageID).Error; err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeNotFound, "套餐使用记录不存在") } return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败") } // 幂等检查:非待生效状态直接返回 if usage.Status != constants.PackageUsageStatusPending { s.logger.Info("套餐无需激活(非待生效状态)", zap.Uint("usage_id", usage.ID), zap.Int("status", usage.Status)) return nil } // 确定载体类型和 ID var carrierType string var carrierID uint if usage.IotCardID > 0 { carrierType = "iot_card" carrierID = usage.IotCardID } else if usage.DeviceID > 0 { carrierType = "device" carrierID = usage.DeviceID } else { return errors.New(errors.CodeInvalidParam, "套餐使用记录缺少载体信息") } // 获取分布式锁 lockKey := constants.RedisPackageActivationLockKey(carrierType, carrierID) lockValue := time.Now().String() locked, err := s.redis.SetNX(ctx, lockKey, lockValue, 30*time.Second).Result() if err != nil { return errors.Wrap(errors.CodeRedisError, err, "获取分布式锁失败") } if !locked { s.logger.Warn("套餐激活正在进行中,跳过", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID)) return nil } defer s.redis.Del(ctx, lockKey) // 加载关联 Package var pkg model.Package if err := s.db.WithContext(ctx).First(&pkg, usage.PackageID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败") } // 事务内激活 return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 再次幂等检查(防止锁释放期间被其他操作激活) var currentUsage model.PackageUsage if err := tx.First(¤tUsage, packageUsageID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "重新查询套餐使用记录失败") } if currentUsage.Status != constants.PackageUsageStatusPending { return nil } if currentUsage.MasterUsageID == nil { hasActive, err := s.hasActiveMainPackage(ctx, tx, carrierType, carrierID) if err != nil { return err } if hasActive { s.logger.Info("已有生效主套餐,跳过指定套餐激活", zap.Uint("usage_id", currentUsage.ID), zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID)) return nil } canActivate, err := s.canActivatePendingUsage(ctx, tx, ¤tUsage, carrierType, carrierID) if err != nil { return err } if !canActivate { s.logger.Info("指定套餐暂不满足激活条件", zap.Uint("usage_id", currentUsage.ID), zap.Bool("pending_realname_activation", currentUsage.PendingRealnameActivation)) return nil } } if err := s.activatePendingUsage(ctx, tx, ¤tUsage, &pkg, carrierType, carrierID, time.Now(), "指定套餐已激活"); err != nil { return err } // 异步复机 if s.resumeCallback != nil { go func(ct string, cid uint) { if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil { s.logger.Error("激活后自动复机失败", zap.String("carrier_type", ct), zap.Uint("carrier_id", cid), zap.Error(err)) } }(carrierType, carrierID) } return nil }) } // ActivateNextPendingMainPackage 按购买顺序激活载体的下一个待生效主套餐。 // 返回 true 表示本次成功激活了一个主套餐;队首套餐未满足实名条件时返回 false。 func (s *ActivationService) ActivateNextPendingMainPackage(ctx context.Context, carrierType string, carrierID uint) (bool, error) { if carrierType != constants.AssetTypeIotCard && carrierType != constants.AssetTypeDevice { return false, errors.New(errors.CodeInvalidParam, "无效的载体类型") } if carrierID == 0 { return false, errors.New(errors.CodeInvalidParam, "无效的载体ID") } lockKey := constants.RedisPackageActivationLockKey(carrierType, carrierID) lockValue := time.Now().String() locked, err := s.redis.SetNX(ctx, lockKey, lockValue, 30*time.Second).Result() if err != nil { return false, errors.Wrap(errors.CodeRedisError, err, "获取分布式锁失败") } if !locked { s.logger.Warn("套餐激活正在进行中,跳过", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID)) return false, errors.New(errors.CodePackageActivationConflict) } defer s.redis.Del(ctx, lockKey) activated := false err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { hasActive, err := s.hasActiveMainPackage(ctx, tx, carrierType, carrierID) if err != nil { return err } if hasActive { return nil } nextMain, err := s.getNextPendingMainPackage(ctx, tx, carrierType, carrierID) if err == gorm.ErrRecordNotFound { return nil } if err != nil { return err } canActivate, err := s.canActivatePendingUsage(ctx, tx, nextMain, carrierType, carrierID) if err != nil { return err } if !canActivate { s.logger.Info("队首待生效套餐暂不满足激活条件", zap.Uint("usage_id", nextMain.ID), zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID), zap.Bool("pending_realname_activation", nextMain.PendingRealnameActivation)) return nil } var pkg model.Package if err := tx.First(&pkg, nextMain.PackageID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败") } if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, time.Now(), "队首待生效套餐已激活"); err != nil { return err } activated = true return nil }) if err != nil { return false, err } return activated, nil } // HasActiveMainPackage 查询载体当前是否存在生效中的主套餐。 func (s *ActivationService) HasActiveMainPackage(ctx context.Context, carrierType string, carrierID uint) (bool, error) { return s.hasActiveMainPackage(ctx, s.db.WithContext(ctx), carrierType, carrierID) } // invalidateAddons 任务 9.7: 加油包级联失效 func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) error { var addons []*model.PackageUsage if err := tx.Where("master_usage_id = ?", masterUsageID). Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusPending}). Find(&addons).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询加油包失败") } if len(addons) == 0 { return nil } addonIDs := make([]uint, len(addons)) for i, addon := range addons { addonIDs[i] = addon.ID } // 批量更新加油包状态为已失效 if err := tx.Model(&model.PackageUsage{}). Where("id IN ?", addonIDs). Update("status", constants.PackageUsageStatusInvalidated).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "批量失效加油包失败") } s.logger.Info("加油包已级联失效", zap.Uint("master_usage_id", masterUsageID), zap.Int("addon_count", len(addons))) return nil } // activateNextMainPackage 任务 9.6: 激活下一个待生效主套餐 func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint, now time.Time) error { // 查询下一个待生效主套餐 nextMain, err := s.getNextPendingMainPackage(ctx, tx, carrierType, carrierID) if err == gorm.ErrRecordNotFound { s.logger.Info("没有待生效的主套餐", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID)) return nil } if err != nil { return err } canActivate, err := s.canActivatePendingUsage(ctx, tx, nextMain, carrierType, carrierID) if err != nil { return err } if !canActivate { s.logger.Info("下一个待生效主套餐暂不满足激活条件", zap.Uint("usage_id", nextMain.ID), zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID), zap.Bool("pending_realname_activation", nextMain.PendingRealnameActivation)) return nil } // 查询套餐信息 var pkg model.Package if err := tx.First(&pkg, nextMain.PackageID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败") } return s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, now, "排队主套餐已激活") } func (s *ActivationService) getNextPendingMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (*model.PackageUsage, error) { var nextMain model.PackageUsage err := tx.WithContext(ctx). Where("status = ?", constants.PackageUsageStatusPending). Where("master_usage_id IS NULL"). Where(carrierType+"_id = ?", carrierID). Order("priority ASC, created_at ASC"). First(&nextMain).Error if err != nil { if err == gorm.ErrRecordNotFound { return nil, err } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询下一个待生效主套餐失败") } return &nextMain, nil } func (s *ActivationService) hasActiveMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) { var count int64 // 生效中(1)和已用完(2)都属于"占位"状态:已用完的套餐仍在有效期内,不允许提前激活下一个主套餐 if err := tx.WithContext(ctx). Model(&model.PackageUsage{}). Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}). Where("master_usage_id IS NULL"). Where(carrierType+"_id = ?", carrierID). Count(&count).Error; err != nil { return false, errors.Wrap(errors.CodeDatabaseError, err, "检查生效中主套餐失败") } return count > 0, nil } func (s *ActivationService) canActivatePendingUsage(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, carrierType string, carrierID uint) (bool, error) { if usage == nil { return false, nil } if !usage.PendingRealnameActivation { return true, nil } return s.isCarrierRealnamed(ctx, tx, carrierType, carrierID) } func (s *ActivationService) isCarrierRealnamed(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) { switch carrierType { case constants.AssetTypeIotCard: var card model.IotCard if err := tx.WithContext(ctx). Select("real_name_status"). First(&card, carrierID).Error; err != nil { return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡实名状态失败") } return card.RealNameStatus == constants.RealNameStatusVerified, nil case constants.AssetTypeDevice: var count int64 subQuery := tx.WithContext(ctx). Model(&model.DeviceSimBinding{}). Select("iot_card_id"). Where("device_id = ? AND bind_status = ?", carrierID, constants.BindStatusBound) if err := tx.WithContext(ctx). Model(&model.IotCard{}). Where("id IN (?) AND real_name_status = ?", subQuery, constants.RealNameStatusVerified). Count(&count).Error; err != nil { return false, errors.Wrap(errors.CodeDatabaseError, err, "查询设备实名状态失败") } return count > 0, nil default: return false, errors.New(errors.CodeInvalidParam, "无效的载体类型") } } func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, carrierType string, carrierID uint, now time.Time, logMessage string) error { var activatedAt time.Time if pkg.ExpiryBase == "from_purchase" { activatedAt = usage.CreatedAt } else { activatedAt = now } expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays) // 计算下次重置时间 nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt) // 更新套餐使用记录 updates := map[string]interface{}{ "status": constants.PackageUsageStatusActive, "pending_realname_activation": false, "activated_at": activatedAt, "expires_at": expiresAt, } if nextResetAt != nil { updates["next_reset_at"] = *nextResetAt } if err := tx.Model(usage).Updates(updates).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "激活排队主套餐失败") } s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID) s.logger.Info(logMessage, zap.Uint("usage_id", usage.ID), zap.Uint("package_id", usage.PackageID), zap.Time("activated_at", activatedAt), zap.Time("expires_at", expiresAt)) if s.resumeCallback != nil { go func(ct string, cid uint) { if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil { s.logger.Error("排队激活后自动复机失败", zap.String("carrier_type", ct), zap.Uint("carrier_id", cid), zap.Error(err)) } }(carrierType, carrierID) } return nil } func (s *ActivationService) syncCarrierStatusActivated(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, carrierType string, carrierID uint) { if usage == nil { return } if usage.UsageType == constants.PackageUsageTypeDevice || usage.DeviceID > 0 || carrierType == constants.AssetTypeDevice { // 设备激活状态由“生效中主套餐 + 任意绑定卡已实名”动态计算, // 不再写入设备归属状态字段。 return } cardID := usage.IotCardID if cardID == 0 { cardID = carrierID } if cardID == 0 { return } if err := tx.WithContext(ctx). Model(&model.IotCard{}). Where("id = ? AND status < ?", cardID, constants.IotCardStatusActivated). Update("status", constants.IotCardStatusActivated).Error; err != nil { s.logger.Warn("套餐激活后更新卡状态失败", zap.Uint("iot_card_id", cardID), zap.Uint("usage_id", usage.ID), zap.Error(err)) } } // InvalidatePackagesForRefund 退款精准失效套餐并写入退款快照 // 规则: // 1. 优先按 packageUsageID 精准失效单条套餐(且必须属于当前退款订单) // 2. 未传 packageUsageID 时,按 orderID 失效该订单生成的套餐 // 3. 若命中主套餐,级联失效其加油包,避免残留挂靠关系 func (s *ActivationService) InvalidatePackagesForRefund(ctx context.Context, assetType string, assetID uint, orderID uint, refundID uint, refundNo string, packageUsageID *uint) error { if orderID == 0 { return errors.New(errors.CodeInvalidParam, "无效的订单ID") } validStatuses := []int{ constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted, } baseQuery := s.db.WithContext(ctx).Model(&model.PackageUsage{}) switch assetType { case "iot_card": baseQuery = baseQuery.Where("iot_card_id = ?", assetID) case "device": baseQuery = baseQuery.Where("device_id = ?", assetID) default: return errors.New(errors.CodeInvalidParam, "无效的资产类型") } var targets []model.PackageUsage if packageUsageID != nil && *packageUsageID > 0 { var usage model.PackageUsage err := baseQuery. Where("id = ? AND order_id = ?", *packageUsageID, orderID). Where("status IN ?", validStatuses). First(&usage).Error if err != nil { if err == gorm.ErrRecordNotFound { s.logger.Info("退款精准失效:未命中可失效套餐", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.Uint("order_id", orderID), zap.Uint("package_usage_id", *packageUsageID), ) return nil } return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联套餐失败") } targets = append(targets, usage) } else { if err := baseQuery. Where("order_id = ?", orderID). Where("status IN ?", validStatuses). Find(&targets).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询退款订单套餐失败") } if len(targets) == 0 { s.logger.Info("退款精准失效:订单无可失效套餐", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.Uint("order_id", orderID), ) return nil } } targetIDSet := make(map[uint]struct{}, len(targets)) mainUsageIDs := make([]uint, 0, len(targets)) for _, usage := range targets { targetIDSet[usage.ID] = struct{}{} if usage.MasterUsageID == nil { mainUsageIDs = append(mainUsageIDs, usage.ID) } } if len(mainUsageIDs) > 0 { var addons []model.PackageUsage if err := baseQuery. Where("master_usage_id IN ?", mainUsageIDs). Where("status IN ?", validStatuses). Find(&addons).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐关联加油包失败") } for _, addon := range addons { targetIDSet[addon.ID] = struct{}{} } } targetIDs := make([]uint, 0, len(targetIDSet)) for id := range targetIDSet { targetIDs = append(targetIDs, id) } if len(targetIDs) == 0 { return nil } updates := map[string]any{ "status": constants.PackageUsageStatusInvalidated, } if refundID > 0 { updates["refund_id"] = refundID } if refundNo != "" { updates["refund_no"] = refundNo } result := s.db.WithContext(ctx). Model(&model.PackageUsage{}). Where("id IN ?", targetIDs). Updates(updates) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "退款失效套餐失败") } s.logger.Info("退款精准失效套餐完成", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.Uint("order_id", orderID), zap.Uint("refund_id", refundID), zap.String("refund_no", refundNo), zap.Int("target_count", len(targetIDs)), zap.Int64("affected", result.RowsAffected), ) return nil } // InvalidateAllPackagesByAsset 批量失效资产关联的所有有效套餐 // 退款时调用:将该资产下状态为待生效(0)、生效中(1)、已用完(2)的套餐全部标记为已失效(4) func (s *ActivationService) InvalidateAllPackagesByAsset(ctx context.Context, assetType string, assetID uint) error { query := s.db.WithContext(ctx). Model(&model.PackageUsage{}). Where("status IN ?", []int{ constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted, }) switch assetType { case "iot_card": query = query.Where("iot_card_id = ?", assetID) case "device": query = query.Where("device_id = ?", assetID) default: return errors.New(errors.CodeInvalidParam, "无效的资产类型") } result := query.Update("status", constants.PackageUsageStatusInvalidated) if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量失效套餐失败") } s.logger.Info("批量失效套餐完成", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.Int64("affected", result.RowsAffected), ) return nil }